diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..8d07aef Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cdf65d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# --- secrets / env(不要 commit 實值)--- +infra/.env +infra/etc/haixun.env +/opt/haixun/ + +# --- build 產物 --- +backend/bin/ +frontend/dist/ + +# --- 依賴 --- +node_modules/ + +# --- 其他 --- +*.log +.DS_Store diff --git a/Makefile b/Makefile index 8570fa2..249a020 100644 --- a/Makefile +++ b/Makefile @@ -1,98 +1,229 @@ -GO ?= go -GOFMT ?= gofmt -GOCTL ?= goctl -GO_ZERO_STYLE := go_zero -API_ENTRY := ./generate/api/gateway.api -GOFILES := $(shell find . -name '*.go') +# 巡樓 monorepo Makefile +# 兩種模式: +# dev - 本機開發(docker 起 Mongo/Redis,go run / vite dev) +# prod - 建置產物(前端 dist + linux Go binary),並可部署成 systemd 服務 + nginx +# +# 常用: +# make dev-infra # 起本機 Mongo/Redis +# make dev-backend # 跑 gateway (:8890) +# make dev-frontend # 跑前端 (:5173,proxy 到 :8890) +# make build # 產出 frontend/dist + backend/bin/{gateway,worker} +# sudo make install # 部署到目標主機(見 infra/README.md) + +SHELL := /bin/bash + +# --- 路徑 --- +BACKEND_DIR := backend +FRONTEND_DIR := frontend +INFRA_DIR := infra +BIN_DIR := $(BACKEND_DIR)/bin + +# --- 部署目標(install 用)--- +DEPLOY_ROOT := /opt/haixun +WEB_ROOT := /var/www/haixun + +# --- 交叉編譯目標(在 mac 上 build 給 linux 主機)--- +GOOS ?= linux +GOARCH ?= amd64 + +# --- docker compose --- +COMPOSE := docker compose -f $(INFRA_DIR)/docker-compose.yml --env-file $(INFRA_DIR)/.env + +# --- dev 用 worker secret(對應 etc/gateway.yaml 的 InternalWorker.Secret)--- +DEV_WORKER_SECRET := haixun-dev-worker-secret +DEV_BACKEND_URL := http://127.0.0.1:8890 .DEFAULT_GOAL := help +.PHONY: help help: ## 顯示可用指令 - @echo "Haixun Backend" + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' + +# ============================================================ +# DEV +# ============================================================ + +$(INFRA_DIR)/.env: + @test -f $(INFRA_DIR)/.env || (cp $(INFRA_DIR)/.env.example $(INFRA_DIR)/.env && echo "已從 .env.example 建立 $(INFRA_DIR)/.env,請視需要修改密碼") + +.PHONY: dev-infra +dev-infra: $(INFRA_DIR)/.env ## [dev] 起本機 Mongo + Redis (docker) + $(COMPOSE) up -d + $(COMPOSE) ps + +.PHONY: dev-infra-down +dev-infra-down: ## [dev] 停掉本機 Mongo + Redis + $(COMPOSE) down + +.PHONY: dev-backend +dev-backend: ## [dev] 跑 gateway API (:8890) + cd $(BACKEND_DIR) && go run . -f etc/gateway.yaml + +.PHONY: dev-worker +dev-worker: ## [dev] 跑 Go job worker (:8891) + cd $(BACKEND_DIR) && go run ./cmd/worker -f etc/gateway.worker.yaml + +.PHONY: dev-node-worker +dev-node-worker: ## [dev] 跑 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: dev-frontend +dev-frontend: ## [dev] 跑前端 dev server (:5173) + cd $(FRONTEND_DIR) && npm install && npm run dev + +.PHONY: dev-init +dev-init: ## [dev] 初始化 DB(索引/權限)並建立 admin 帳號(可用 INIT_ADMIN_EMAIL / INIT_ADMIN_PASSWORD 覆寫) + cd $(BACKEND_DIR) && go run ./cmd/tool init -f etc/gateway.yaml + +.PHONY: dev +dev: ## [dev] 顯示本機開發要開的終端 + @echo "本機開發請分開幾個終端執行:" + @echo " 1) make dev-infra # Mongo/Redis" + @echo " 2) make dev-backend # gateway :8890" + @echo " 3) make dev-worker # go worker(可選)" + @echo " 4) make dev-node-worker # node worker(需 style-8d 時)" + @echo " 5) make dev-frontend # 前端 :5173" + +# ============================================================ +# 安裝依賴(新機器 clone 後執行一次) +# ============================================================ + +.PHONY: bootstrap-sys +bootstrap-sys: ## [需 sudo] 安裝系統依賴(Go, Node.js, Docker, Nginx)— Ubuntu 專用 + @echo "=== 安裝系統套件: nginx, curl, gnupg ===" + sudo apt-get update -qq + sudo apt-get install -y -qq nginx curl gnupg ca-certificates + @echo "" + @echo "=== 安裝 Go 1.22+ ===" + @GO_VER=$$(curl -sL https://go.dev/VERSION?m=text 2>/dev/null | head -1 || echo "go1.22"); \ + echo "下載 $$GO_VER.linux-amd64.tar.gz"; \ + curl -sL "https://go.dev/dl/$$GO_VER.linux-amd64.tar.gz" -o /tmp/go.tar.gz && \ + sudo rm -rf /usr/local/go && \ + sudo tar -C /usr/local -xzf /tmp/go.tar.gz && \ + rm /tmp/go.tar.gz; \ + echo 'export PATH=/usr/local/go/bin:$$PATH' | sudo tee /etc/profile.d/go.sh + @echo "" + @echo "=== 安裝 Node.js LTS ===" + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + sudo apt-get install -y -qq nodejs + @echo "" + @echo "=== 安裝 Docker Engine + Compose ===" + curl -fsSL https://get.docker.com | sudo sh + sudo usermod -aG docker $(USER) 2>/dev/null || true + @echo "" + @echo "=== 安裝 Nginx ===" + sudo apt-get install -y -qq nginx + @echo "" + @echo "✓ 系統依賴安裝完成。" + @echo " 登出再登入後,Go/Node/Docker 即可使用(group 變更生效)。" + @echo " 接著執行 make bootstrap 安裝專案層依賴。" @echo "" - @grep -E '^[a-zA-Z0-9_-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "}; {printf " make %-12s %s\n", $$1, $$2}' -tools: ## 安裝 goctl / goimports - @command -v $(GOCTL) >/dev/null 2>&1 || (echo ">> installing goctl" && $(GO) install github.com/zeromicro/go-zero/tools/goctl@latest) - @command -v goimports >/dev/null 2>&1 || (echo ">> installing goimports" && $(GO) install golang.org/x/tools/cmd/goimports@latest) +.PHONY: bootstrap +bootstrap: ## 安裝專案層依賴(Go modules + npm + Playwright),需先執行 make bootstrap-sys + @echo "=== [1/4] Go modules ===" + cd $(BACKEND_DIR) && go mod tidy + @echo "" + @echo "=== [2/4] 前端 npm ===" + cd $(FRONTEND_DIR) && npm install + @echo "" + @echo "=== [3/4] Node worker npm ===" + cd $(BACKEND_DIR)/worker && npm install + @echo "" + @echo "=== [4/4] Playwright 瀏覽器(chromium)+ 系統依賴 ===" + cd $(BACKEND_DIR)/worker && sudo npx playwright install --with-deps chromium + @echo "" + @echo "✓ 全裝完成。後續步驟:" + @echo " 1) make dev-infra # 起 Mongo/Redis (Docker)" + @echo " 2) make dev-init # 初始化 DB + 建立 admin" + @echo " 3) make dev-backend # 啟動 gateway (:8890)" + @echo " 4) sudo make install # 正式部署(含 systemd + nginx)" -gen-api: tools ## 由 .api 生成 handler / logic / types - $(GOCTL) api go -api $(API_ENTRY) -dir . -style $(GO_ZERO_STYLE) -home generate/goctl +# ============================================================ +# BUILD (prod 產物) +# ============================================================ -fmt: ## gofmt + goimports - $(GOFMT) -s -w $(GOFILES) - @command -v goimports >/dev/null 2>&1 && goimports -w . || true +.PHONY: build +build: build-frontend build-backend ## [prod] 建置前端 dist 與 Go binary -test: ## 執行測試 - $(GO) test ./... +.PHONY: build-frontend +build-frontend: ## [prod] 前端靜態建置 (tsc + vite) -> frontend/dist + cd $(FRONTEND_DIR) && npm ci && npm run build -run: ## 啟動 API(前景) - $(GO) run ./gateway.go -f etc/gateway.yaml +.PHONY: build-backend +build-backend: ## [prod] 交叉編譯 gateway + worker -> backend/bin (linux) + cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \ + go build -trimpath -ldflags "-s -w" -o bin/gateway . + cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \ + go build -trimpath -ldflags "-s -w" -o bin/worker ./cmd/worker + cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \ + go build -trimpath -ldflags "-s -w" -o bin/tool ./cmd/tool + @echo "binary 已輸出到 $(BIN_DIR)/(gateway / worker / tool)" -dev-all: ## 一鍵啟動 Mongo/Redis + API + 前端 + 8D worker(背景) - bash scripts/start-all.sh +# ============================================================ +# PROD (部署) +# ============================================================ -stop-all: ## 一鍵停止全部開發服務 - bash scripts/stop-all.sh +.PHONY: prod-infra +prod-infra: $(INFRA_DIR)/.env ## [prod] 起 Mongo + Redis (docker,背景) + $(COMPOSE) up -d -restart-all: ## 一鍵重啟全部開發服務 - bash scripts/restart-all.sh +.PHONY: prod-infra-down +prod-infra-down: ## [prod] 停掉 Mongo + Redis + $(COMPOSE) down -status-all: ## 查看全部開發服務狀態 - bash scripts/status-all.sh +.PHONY: prod-init +prod-init: ## [prod] 目標主機初始化 DB + 建立 admin(讀 /opt/haixun/etc/haixun.env;可加 INIT_ADMIN_EMAIL/PASSWORD) + @test -x $(DEPLOY_ROOT)/bin/tool || (echo "缺少 $(DEPLOY_ROOT)/bin/tool,請先 make install" && exit 1) + set -a; . $(DEPLOY_ROOT)/etc/haixun.env; set +a; \ + $(DEPLOY_ROOT)/bin/tool init -f $(DEPLOY_ROOT)/etc/gateway.prod.yaml -stop: stop-all ## 同 stop-all +.PHONY: install +install: ## [prod] 安裝 binary/前端/設定/systemd/nginx(需 root,在目標主機執行) + @test -f $(BIN_DIR)/gateway || (echo "缺少 $(BIN_DIR)/gateway,請先在能 build 的機器執行 make build" && exit 1) + @test -d $(FRONTEND_DIR)/dist || (echo "缺少 $(FRONTEND_DIR)/dist,請先 make build-frontend" && exit 1) + id haixun >/dev/null 2>&1 || useradd --system --home $(DEPLOY_ROOT) --shell /usr/sbin/nologin haixun + install -d $(DEPLOY_ROOT)/bin $(DEPLOY_ROOT)/etc $(DEPLOY_ROOT)/node-worker $(WEB_ROOT) + install -m 0755 $(BIN_DIR)/gateway $(DEPLOY_ROOT)/bin/gateway + install -m 0755 $(BIN_DIR)/worker $(DEPLOY_ROOT)/bin/worker + install -m 0755 $(BIN_DIR)/tool $(DEPLOY_ROOT)/bin/tool + install -m 0644 $(BACKEND_DIR)/etc/gateway.prod.yaml $(DEPLOY_ROOT)/etc/gateway.prod.yaml + install -m 0644 $(BACKEND_DIR)/etc/gateway.worker.prod.yaml $(DEPLOY_ROOT)/etc/gateway.worker.prod.yaml + rm -rf $(WEB_ROOT)/* && cp -r $(FRONTEND_DIR)/dist/* $(WEB_ROOT)/ + cp -r $(BACKEND_DIR)/worker/* $(DEPLOY_ROOT)/node-worker/ + cd $(DEPLOY_ROOT)/node-worker && npm ci && npx playwright install --with-deps chromium + install -m 0644 $(INFRA_DIR)/systemd/haixun-gateway.service /etc/systemd/system/haixun-gateway.service + install -m 0644 $(INFRA_DIR)/systemd/haixun-worker.service /etc/systemd/system/haixun-worker.service + install -m 0644 $(INFRA_DIR)/systemd/haixun-node-worker.service /etc/systemd/system/haixun-node-worker.service + install -m 0644 $(INFRA_DIR)/nginx/haixun.conf /etc/nginx/conf.d/haixun.conf + chown -R haixun:haixun $(DEPLOY_ROOT) $(WEB_ROOT) + @echo "----" + @echo "接著(只做一次)建立 secret 檔:" + @echo " cp $(INFRA_DIR)/etc/haixun.env.example $(DEPLOY_ROOT)/etc/haixun.env && chmod 600 $(DEPLOY_ROOT)/etc/haixun.env && sudoedit $(DEPLOY_ROOT)/etc/haixun.env" + @echo "再啟用服務:" + @echo " systemctl daemon-reload && systemctl enable --now haixun-gateway haixun-worker haixun-node-worker" + @echo " nginx -t && systemctl reload nginx" -restart: restart-all ## 同 restart-all +# ============================================================ +# 驗證 / 維護 +# ============================================================ -dev-8d: ## 一鍵啟動 API + Node 8D worker(前景,Ctrl+C 結束) - bash scripts/dev-with-style-8d.sh +.PHONY: tidy +tidy: ## go mod tidy + cd $(BACKEND_DIR) && go mod tidy -CONFIG ?= etc/gateway.yaml -INIT_TENANT ?= default -INIT_EMAIL ?= admin@30cm.net -INIT_PASSWORD ?= Fafafa54088 +.PHONY: fmt +fmt: ## gofmt 後端 + cd $(BACKEND_DIR) && gofmt -w . -tool-init: ## 初始化 Mongo indexes、預設權限與 admin 帳號 - $(GO) run ./cmd/tool init -f $(CONFIG) -tenant $(INIT_TENANT) -email $(INIT_EMAIL) -password '$(INIT_PASSWORD)' +.PHONY: test +test: ## 跑後端測試 + cd $(BACKEND_DIR) && go test ./... -tool: ## 執行 cmd/tool(例:make tool ARGS="init -f etc/gateway.yaml") - $(GO) run ./cmd/tool $(ARGS) - -web-install: ## 安裝前端依賴 - cd web && npm install - -web-dev: web-install ## 啟動前端 dev server(proxy 到 :8890) - cd web && npm run dev - -extension-pack: ## 打包 Chrome 擴充為 web/public/downloads/*.zip - bash scripts/package-extension.sh - -web-build: web-install extension-pack ## 建置前端靜態檔 - cd web && npm run build - -node-worker-style-8d: ## 啟動 Node 8D 爬蟲 worker - cd .. && npm run worker:style-8d - -check: fmt test ## 格式化並測試 - -prod: ## 一鍵啟動 production Docker(API + Web + workers,分身數見 deploy/.env) - bash scripts/prod-up.sh - -prod-update: ## 只重建/重啟 API+Web+Workers;mongo/redis 不重啟,資料留在 volume - bash scripts/prod-update.sh - -prod-deps: ## 只啟動 mongo+redis(named volume 持久化) - bash scripts/prod-deps.sh - -prod-down: ## 停止 stack(不刪 volume;Mongo/Redis 資料保留) - bash scripts/prod-down.sh - -prod-wipe-data: ## 停止並刪除 mongo/redis volume(危險,需輸入 yes) - bash scripts/prod-wipe-data.sh - -prod-logs: ## 追蹤 production logs(可傳 service 名,例:make prod-logs ARGS=api) - bash scripts/prod-logs.sh $(ARGS) - -prod-build: web-build ## 建置靜態前端 + production images(不啟動) - cd deploy && docker compose -f docker-compose.prod.yml build +.PHONY: verify +verify: ## 後端 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" diff --git a/backend/.DS_Store b/backend/.DS_Store new file mode 100644 index 0000000..3397521 Binary files /dev/null and b/backend/.DS_Store differ diff --git a/README.md b/backend/README.md similarity index 100% rename from README.md rename to backend/README.md diff --git a/cmd/tool/main.go b/backend/cmd/tool/main.go similarity index 92% rename from cmd/tool/main.go rename to backend/cmd/tool/main.go index cb936de..837a0dc 100644 --- a/cmd/tool/main.go +++ b/backend/cmd/tool/main.go @@ -36,15 +36,15 @@ func runInit(args []string) error { fs := flag.NewFlagSet("init", flag.ExitOnError) 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@haixun.local"), "bootstrap admin email") - password := fs.String("password", envOr("INIT_ADMIN_PASSWORD", "Admin-Pass-1!"), "bootstrap admin password") + 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") displayName := fs.String("display-name", envOr("INIT_ADMIN_DISPLAY_NAME", "Admin"), "bootstrap admin display name") if err := fs.Parse(args); err != nil { return err } var cfg config.Config - conf.MustLoad(*configFile, &cfg) + conf.MustLoad(*configFile, &cfg, conf.UseEnv()) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() diff --git a/cmd/worker/main.go b/backend/cmd/worker/main.go similarity index 94% rename from cmd/worker/main.go rename to backend/cmd/worker/main.go index d669a47..e80b632 100644 --- a/cmd/worker/main.go +++ b/backend/cmd/worker/main.go @@ -20,7 +20,7 @@ func main() { flag.Parse() var c config.Config - conf.MustLoad(*configFile, &c) + conf.MustLoad(*configFile, &c, conf.UseEnv()) if !c.JobWorker.Enabled { fmt.Fprintln(os.Stderr, "[worker] JobWorker.Enabled must be true") os.Exit(1) @@ -40,4 +40,4 @@ func main() { signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) <-ch fmt.Println("[worker] shutting down") -} \ No newline at end of file +} diff --git a/docs/job-system-plan.md b/backend/docs/job-system-plan.md similarity index 100% rename from docs/job-system-plan.md rename to backend/docs/job-system-plan.md diff --git a/backend/docs/phase1-methods.md b/backend/docs/phase1-methods.md new file mode 100644 index 0000000..a91ebe4 --- /dev/null +++ b/backend/docs/phase1-methods.md @@ -0,0 +1,176 @@ +# 全 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`。 diff --git a/docs/scan-placement-plan.md b/backend/docs/scan-placement-plan.md similarity index 100% rename from docs/scan-placement-plan.md rename to backend/docs/scan-placement-plan.md diff --git a/etc/gateway.dev.example.yaml b/backend/etc/gateway.dev.example.yaml similarity index 100% rename from etc/gateway.dev.example.yaml rename to backend/etc/gateway.dev.example.yaml diff --git a/backend/etc/gateway.prod.yaml b/backend/etc/gateway.prod.yaml new file mode 100644 index 0000000..e6ca1e3 --- /dev/null +++ b/backend/etc/gateway.prod.yaml @@ -0,0 +1,41 @@ +Name: haixun-backend +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} + TimeoutSeconds: 10 + +Redis: + Addr: ${HAIXUN_REDIS_ADDR} + Password: ${HAIXUN_REDIS_PASSWORD} + DB: 0 + +Auth: + AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET} + RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET} + AccessExpireSeconds: 900 + RefreshExpireSeconds: 2592000 + DevHeaderFallback: false + +Secrets: + EncryptionKey: ${HAIXUN_SECRETS_KEY} + +InternalWorker: + Secret: ${HAIXUN_WORKER_SECRET} + +JobWorker: + Enabled: false + WorkerType: go + +JobScheduler: + Enabled: true + IntervalSeconds: 60 + +JobReaper: + Enabled: true + IntervalSeconds: 30 diff --git a/deploy/config/gateway.worker.runtime.yaml.tpl b/backend/etc/gateway.worker.prod.yaml similarity index 65% rename from deploy/config/gateway.worker.runtime.yaml.tpl rename to backend/etc/gateway.worker.prod.yaml index c301ec5..9491084 100644 --- a/deploy/config/gateway.worker.runtime.yaml.tpl +++ b/backend/etc/gateway.worker.prod.yaml @@ -5,20 +5,24 @@ Timeout: 120000 Mongo: URI: ${HAIXUN_MONGO_URI} - Database: ${HAIXUN_MONGO_DATABASE} + Database: ${HAIXUN_MONGO_DB} TimeoutSeconds: 10 Redis: Addr: ${HAIXUN_REDIS_ADDR} + Password: ${HAIXUN_REDIS_PASSWORD} DB: 0 Auth: - AccessSecret: ${HAIXUN_AUTH_ACCESS_SECRET} - RefreshSecret: ${HAIXUN_AUTH_REFRESH_SECRET} + AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET} + RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET} AccessExpireSeconds: 900 RefreshExpireSeconds: 2592000 DevHeaderFallback: false +Secrets: + EncryptionKey: ${HAIXUN_SECRETS_KEY} + InternalWorker: Secret: ${HAIXUN_WORKER_SECRET} @@ -32,4 +36,4 @@ JobScheduler: JobReaper: Enabled: false - IntervalSeconds: 30 \ No newline at end of file + IntervalSeconds: 30 diff --git a/etc/gateway.yaml b/backend/etc/gateway.worker.yaml similarity index 51% rename from etc/gateway.yaml rename to backend/etc/gateway.worker.yaml index eeaffea..05dfaba 100644 --- a/etc/gateway.yaml +++ b/backend/etc/gateway.worker.yaml @@ -1,15 +1,17 @@ -Name: haixun-backend +Name: haixun-worker Host: 0.0.0.0 -Port: 8890 +Port: 8891 Timeout: 120000 +# 本機開發 worker 設定(go worker)。搭配 `make dev-infra` 的 Mongo/Redis。 Mongo: - URI: mongodb://127.0.0.1:27017 + URI: mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin Database: haixun TimeoutSeconds: 10 Redis: Addr: 127.0.0.1:6379 + Password: change-me-redis-pass DB: 0 Auth: @@ -17,16 +19,22 @@ Auth: RefreshSecret: haixun-dev-refresh-secret-change-me AccessExpireSeconds: 900 RefreshExpireSeconds: 2592000 - DevHeaderFallback: true + DevHeaderFallback: false + +Secrets: + EncryptionKey: "" + +InternalWorker: + Secret: haixun-dev-worker-secret JobWorker: Enabled: true WorkerType: go JobScheduler: - Enabled: true + Enabled: false IntervalSeconds: 60 JobReaper: - Enabled: true + Enabled: false IntervalSeconds: 30 diff --git a/backend/etc/gateway.yaml b/backend/etc/gateway.yaml new file mode 100644 index 0000000..399b90b --- /dev/null +++ b/backend/etc/gateway.yaml @@ -0,0 +1,43 @@ +Name: haixun-backend +Host: 0.0.0.0 +Port: 8890 +Timeout: 120000 + +# 本機開發設定。預設搭配 `make dev-infra`(infra/docker-compose.yml)跑的 Mongo/Redis, +# 帳密對應 infra/.env.example 的預設值。若你改了 .env 密碼,這裡也要同步。 +Mongo: + URI: mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin + Database: haixun + TimeoutSeconds: 10 + +Redis: + Addr: 127.0.0.1:6379 + Password: change-me-redis-pass + 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 + +JobWorker: + Enabled: true + WorkerType: go + +JobScheduler: + Enabled: true + IntervalSeconds: 60 + +JobReaper: + Enabled: true + IntervalSeconds: 30 diff --git a/gateway.go b/backend/gateway.go similarity index 92% rename from gateway.go rename to backend/gateway.go index ab4a148..78a8bd2 100644 --- a/gateway.go +++ b/backend/gateway.go @@ -19,7 +19,7 @@ func main() { flag.Parse() var c config.Config - conf.MustLoad(*configFile, &c) + conf.MustLoad(*configFile, &c, conf.UseEnv()) server := rest.MustNewServer(c.RestConf) defer server.Stop() diff --git a/generate/api/ai.api b/backend/generate/api/ai.api similarity index 100% rename from generate/api/ai.api rename to backend/generate/api/ai.api diff --git a/generate/api/auth.api b/backend/generate/api/auth.api similarity index 100% rename from generate/api/auth.api rename to backend/generate/api/auth.api diff --git a/generate/api/brand.api b/backend/generate/api/brand.api similarity index 100% rename from generate/api/brand.api rename to backend/generate/api/brand.api diff --git a/generate/api/common.api b/backend/generate/api/common.api similarity index 100% rename from generate/api/common.api rename to backend/generate/api/common.api diff --git a/generate/api/copy_mission.api b/backend/generate/api/copy_mission.api similarity index 100% rename from generate/api/copy_mission.api rename to backend/generate/api/copy_mission.api diff --git a/generate/api/gateway.api b/backend/generate/api/gateway.api similarity index 100% rename from generate/api/gateway.api rename to backend/generate/api/gateway.api diff --git a/generate/api/job.api b/backend/generate/api/job.api similarity index 100% rename from generate/api/job.api rename to backend/generate/api/job.api diff --git a/generate/api/member.api b/backend/generate/api/member.api similarity index 100% rename from generate/api/member.api rename to backend/generate/api/member.api diff --git a/generate/api/normal.api b/backend/generate/api/normal.api similarity index 100% rename from generate/api/normal.api rename to backend/generate/api/normal.api diff --git a/generate/api/permission.api b/backend/generate/api/permission.api similarity index 100% rename from generate/api/permission.api rename to backend/generate/api/permission.api diff --git a/generate/api/persona.api b/backend/generate/api/persona.api similarity index 100% rename from generate/api/persona.api rename to backend/generate/api/persona.api diff --git a/generate/api/placement_topic.api b/backend/generate/api/placement_topic.api similarity index 100% rename from generate/api/placement_topic.api rename to backend/generate/api/placement_topic.api diff --git a/generate/api/setting.api b/backend/generate/api/setting.api similarity index 100% rename from generate/api/setting.api rename to backend/generate/api/setting.api diff --git a/generate/api/threads_account.api b/backend/generate/api/threads_account.api similarity index 87% rename from generate/api/threads_account.api rename to backend/generate/api/threads_account.api index 51b8253..93396a3 100644 --- a/generate/api/threads_account.api +++ b/backend/generate/api/threads_account.api @@ -115,6 +115,28 @@ type ( ThreadsAccountPath UpdateThreadsAccountAiSettingsReq } + + ThreadsAccountAiProviderPath { + ID string `path:"id" validate:"required"` + Provider string `path:"provider" validate:"required,oneof=opencode-go xai"` + } + + ListThreadsAccountAiProviderModelsReq { + ApiKey string `json:"api_key,optional"` // 選填;未帶則用已儲存的 key + } + + ThreadsAccountAiProviderModelsHandlerReq { + ThreadsAccountAiProviderPath + ListThreadsAccountAiProviderModelsReq + } + + ThreadsAccountAiProviderModelsData { + ID string `json:"id"` + Label string `json:"label"` + Models []string `json:"models"` + Streams bool `json:"streams"` + Error string `json:"error,optional"` + } ) @server( @@ -154,4 +176,7 @@ service gateway { @handler updateThreadsAccountAiSettings put /:id/ai-settings (UpdateThreadsAccountAiSettingsHandlerReq) returns (ThreadsAccountAiSettingsData) + + @handler listThreadsAccountAiProviderModels + post /:id/ai-settings/providers/:provider/models (ThreadsAccountAiProviderModelsHandlerReq) returns (ThreadsAccountAiProviderModelsData) } \ No newline at end of file diff --git a/generate/api/worker_internal.api b/backend/generate/api/worker_internal.api similarity index 100% rename from generate/api/worker_internal.api rename to backend/generate/api/worker_internal.api diff --git a/generate/goctl/api/handler.tpl b/backend/generate/goctl/api/handler.tpl similarity index 100% rename from generate/goctl/api/handler.tpl rename to backend/generate/goctl/api/handler.tpl diff --git a/go.mod b/backend/go.mod similarity index 100% rename from go.mod rename to backend/go.mod diff --git a/go.sum b/backend/go.sum similarity index 100% rename from go.sum rename to backend/go.sum diff --git a/internal/bootstrap/admin.go b/backend/internal/bootstrap/admin.go similarity index 100% rename from internal/bootstrap/admin.go rename to backend/internal/bootstrap/admin.go diff --git a/internal/bootstrap/admin_test.go b/backend/internal/bootstrap/admin_test.go similarity index 100% rename from internal/bootstrap/admin_test.go rename to backend/internal/bootstrap/admin_test.go diff --git a/internal/bootstrap/init.go b/backend/internal/bootstrap/init.go similarity index 64% rename from internal/bootstrap/init.go rename to backend/internal/bootstrap/init.go index 6706fbd..5b00b39 100644 --- a/internal/bootstrap/init.go +++ b/backend/internal/bootstrap/init.go @@ -5,12 +5,23 @@ import ( "fmt" "haixun-backend/internal/config" + libcrypto "haixun-backend/internal/library/crypto" libmongo "haixun-backend/internal/library/mongo" + brandrepo "haixun-backend/internal/model/brand/repository" + cmatrixrepo "haixun-backend/internal/model/content_matrix/repository" + copydraftrepo "haixun-backend/internal/model/copy_draft/repository" + copymissionrepo "haixun-backend/internal/model/copy_mission/repository" jobrepo "haixun-backend/internal/model/job/repository" + kgrepo "haixun-backend/internal/model/knowledge_graph/repository" memberrepo "haixun-backend/internal/model/member/repository" + outreachdraftrepo "haixun-backend/internal/model/outreach_draft/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" + scanpostrepo "haixun-backend/internal/model/scan_post/repository" settingrepo "haixun-backend/internal/model/setting/repository" + threadsaccountrepo "haixun-backend/internal/model/threads_account/repository" ) type InitOptions struct { @@ -48,6 +59,12 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport db := mongoClient.Database() report := &InitReport{} + // cipher 只用於資料加解密;EnsureIndexes 不會用到,但 secrets repo 建構子需要它。 + secretsCipher, err := libcrypto.New(cfg.Secrets.EncryptionKey) + if err != nil { + return nil, fmt.Errorf("init secrets cipher: %w", err) + } + settingRepository := settingrepo.NewMongoRepository(db) memberRepository := memberrepo.NewMongoRepository(db) permissionRepository := permissionrepo.NewMongoPermissionRepository(db) @@ -69,6 +86,17 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport {"job_runs", jobRunRepository.EnsureIndexes}, {"job_schedules", jobScheduleRepository.EnsureIndexes}, {"job_events", jobEventRepository.EnsureIndexes}, + {"copy_missions", copymissionrepo.NewMongoRepository(db).EnsureIndexes}, + {"copy_drafts", copydraftrepo.NewMongoRepository(db).EnsureIndexes}, + {"scan_posts", scanpostrepo.NewMongoRepository(db).EnsureIndexes}, + {"outreach_drafts", outreachdraftrepo.NewMongoRepository(db).EnsureIndexes}, + {"content_matrix", cmatrixrepo.NewMongoRepository(db).EnsureIndexes}, + {"knowledge_graph", kgrepo.NewMongoRepository(db).EnsureIndexes}, + {"personas", personarepo.NewMongoRepository(db).EnsureIndexes}, + {"brands", brandrepo.NewMongoRepository(db).EnsureIndexes}, + {"placement_topics", placementtopicrepo.NewMongoRepository(db).EnsureIndexes}, + {"threads_accounts", threadsaccountrepo.NewMongoRepository(db).EnsureIndexes}, + {"threads_account_secrets", threadsaccountrepo.NewSecretsMongoRepository(db, secretsCipher).EnsureIndexes}, } for _, repo := range repos { if err := repo.fn(ctx); err != nil { diff --git a/internal/config/config.go b/backend/internal/config/config.go similarity index 76% rename from internal/config/config.go rename to backend/internal/config/config.go index 9b22f72..478ea7a 100644 --- a/internal/config/config.go +++ b/backend/internal/config/config.go @@ -9,8 +9,9 @@ type MongoConf struct { } type RedisConf struct { - Addr string `json:",optional"` - DB int `json:",optional"` + Addr string `json:",optional"` + Password string `json:",optional"` + DB int `json:",optional"` } type JobWorkerConf struct { @@ -34,13 +35,19 @@ type AuthConf struct { RefreshSecret string `json:",optional"` AccessExpireSeconds int64 `json:",default=900"` RefreshExpireSeconds int64 `json:",default=2592000"` - DevHeaderFallback bool `json:",default=true"` + DevHeaderFallback bool `json:",default=false"` } type InternalWorkerConf struct { Secret string `json:",optional"` } +// SecretsConf holds the application-layer encryption key (base64 of 32 bytes) +// used to encrypt sensitive data at rest (browser session, third-party API keys). +type SecretsConf struct { + EncryptionKey string `json:",optional"` +} + type BraveConf struct { APIKey string `json:",optional"` } @@ -50,6 +57,7 @@ type Config struct { 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"` diff --git a/internal/handler/ai/chat_handler.go b/backend/internal/handler/ai/chat_handler.go similarity index 100% rename from internal/handler/ai/chat_handler.go rename to backend/internal/handler/ai/chat_handler.go diff --git a/internal/handler/ai/chat_stream_handler.go b/backend/internal/handler/ai/chat_stream_handler.go similarity index 100% rename from internal/handler/ai/chat_stream_handler.go rename to backend/internal/handler/ai/chat_stream_handler.go diff --git a/internal/handler/ai/islander_chat_stream_handler.go b/backend/internal/handler/ai/islander_chat_stream_handler.go similarity index 100% rename from internal/handler/ai/islander_chat_stream_handler.go rename to backend/internal/handler/ai/islander_chat_stream_handler.go diff --git a/internal/handler/ai/list_ai_provider_models_handler.go b/backend/internal/handler/ai/list_ai_provider_models_handler.go similarity index 100% rename from internal/handler/ai/list_ai_provider_models_handler.go rename to backend/internal/handler/ai/list_ai_provider_models_handler.go diff --git a/internal/handler/ai/list_ai_providers_handler.go b/backend/internal/handler/ai/list_ai_providers_handler.go similarity index 100% rename from internal/handler/ai/list_ai_providers_handler.go rename to backend/internal/handler/ai/list_ai_providers_handler.go diff --git a/internal/handler/auth/login_handler.go b/backend/internal/handler/auth/login_handler.go similarity index 100% rename from internal/handler/auth/login_handler.go rename to backend/internal/handler/auth/login_handler.go diff --git a/internal/handler/auth/logout_handler.go b/backend/internal/handler/auth/logout_handler.go similarity index 100% rename from internal/handler/auth/logout_handler.go rename to backend/internal/handler/auth/logout_handler.go diff --git a/internal/handler/auth/refresh_handler.go b/backend/internal/handler/auth/refresh_handler.go similarity index 100% rename from internal/handler/auth/refresh_handler.go rename to backend/internal/handler/auth/refresh_handler.go diff --git a/internal/handler/auth/register_handler.go b/backend/internal/handler/auth/register_handler.go similarity index 100% rename from internal/handler/auth/register_handler.go rename to backend/internal/handler/auth/register_handler.go diff --git a/internal/handler/brand/create_brand_handler.go b/backend/internal/handler/brand/create_brand_handler.go similarity index 100% rename from internal/handler/brand/create_brand_handler.go rename to backend/internal/handler/brand/create_brand_handler.go diff --git a/internal/handler/brand/create_brand_product_handler.go b/backend/internal/handler/brand/create_brand_product_handler.go similarity index 100% rename from internal/handler/brand/create_brand_product_handler.go rename to backend/internal/handler/brand/create_brand_product_handler.go diff --git a/internal/handler/brand/delete_brand_handler.go b/backend/internal/handler/brand/delete_brand_handler.go similarity index 100% rename from internal/handler/brand/delete_brand_handler.go rename to backend/internal/handler/brand/delete_brand_handler.go diff --git a/internal/handler/brand/delete_brand_product_handler.go b/backend/internal/handler/brand/delete_brand_product_handler.go similarity index 100% rename from internal/handler/brand/delete_brand_product_handler.go rename to backend/internal/handler/brand/delete_brand_product_handler.go diff --git a/internal/handler/brand/expand_knowledge_graph_handler.go b/backend/internal/handler/brand/expand_knowledge_graph_handler.go similarity index 100% rename from internal/handler/brand/expand_knowledge_graph_handler.go rename to backend/internal/handler/brand/expand_knowledge_graph_handler.go diff --git a/internal/handler/brand/generate_brand_content_matrix_handler.go b/backend/internal/handler/brand/generate_brand_content_matrix_handler.go similarity index 100% rename from internal/handler/brand/generate_brand_content_matrix_handler.go rename to backend/internal/handler/brand/generate_brand_content_matrix_handler.go diff --git a/internal/handler/brand/generate_outreach_drafts_handler.go b/backend/internal/handler/brand/generate_outreach_drafts_handler.go similarity index 100% rename from internal/handler/brand/generate_outreach_drafts_handler.go rename to backend/internal/handler/brand/generate_outreach_drafts_handler.go diff --git a/internal/handler/brand/get_brand_content_matrix_handler.go b/backend/internal/handler/brand/get_brand_content_matrix_handler.go similarity index 100% rename from internal/handler/brand/get_brand_content_matrix_handler.go rename to backend/internal/handler/brand/get_brand_content_matrix_handler.go diff --git a/internal/handler/brand/get_brand_handler.go b/backend/internal/handler/brand/get_brand_handler.go similarity index 100% rename from internal/handler/brand/get_brand_handler.go rename to backend/internal/handler/brand/get_brand_handler.go diff --git a/internal/handler/brand/get_brand_scan_schedule_handler.go b/backend/internal/handler/brand/get_brand_scan_schedule_handler.go similarity index 100% rename from internal/handler/brand/get_brand_scan_schedule_handler.go rename to backend/internal/handler/brand/get_brand_scan_schedule_handler.go diff --git a/internal/handler/brand/get_knowledge_graph_handler.go b/backend/internal/handler/brand/get_knowledge_graph_handler.go similarity index 100% rename from internal/handler/brand/get_knowledge_graph_handler.go rename to backend/internal/handler/brand/get_knowledge_graph_handler.go diff --git a/internal/handler/brand/list_brand_products_handler.go b/backend/internal/handler/brand/list_brand_products_handler.go similarity index 100% rename from internal/handler/brand/list_brand_products_handler.go rename to backend/internal/handler/brand/list_brand_products_handler.go diff --git a/internal/handler/brand/list_brand_scan_posts_handler.go b/backend/internal/handler/brand/list_brand_scan_posts_handler.go similarity index 100% rename from internal/handler/brand/list_brand_scan_posts_handler.go rename to backend/internal/handler/brand/list_brand_scan_posts_handler.go diff --git a/internal/handler/brand/list_brands_handler.go b/backend/internal/handler/brand/list_brands_handler.go similarity index 100% rename from internal/handler/brand/list_brands_handler.go rename to backend/internal/handler/brand/list_brands_handler.go diff --git a/internal/handler/brand/patch_knowledge_graph_nodes_handler.go b/backend/internal/handler/brand/patch_knowledge_graph_nodes_handler.go similarity index 100% rename from internal/handler/brand/patch_knowledge_graph_nodes_handler.go rename to backend/internal/handler/brand/patch_knowledge_graph_nodes_handler.go diff --git a/internal/handler/brand/patch_scan_post_outreach_handler.go b/backend/internal/handler/brand/patch_scan_post_outreach_handler.go similarity index 100% rename from internal/handler/brand/patch_scan_post_outreach_handler.go rename to backend/internal/handler/brand/patch_scan_post_outreach_handler.go diff --git a/internal/handler/brand/publish_outreach_draft_handler.go b/backend/internal/handler/brand/publish_outreach_draft_handler.go similarity index 100% rename from internal/handler/brand/publish_outreach_draft_handler.go rename to backend/internal/handler/brand/publish_outreach_draft_handler.go diff --git a/internal/handler/brand/start_brand_scan_job_handler.go b/backend/internal/handler/brand/start_brand_scan_job_handler.go similarity index 100% rename from internal/handler/brand/start_brand_scan_job_handler.go rename to backend/internal/handler/brand/start_brand_scan_job_handler.go diff --git a/internal/handler/brand/update_brand_handler.go b/backend/internal/handler/brand/update_brand_handler.go similarity index 100% rename from internal/handler/brand/update_brand_handler.go rename to backend/internal/handler/brand/update_brand_handler.go diff --git a/internal/handler/brand/update_brand_product_handler.go b/backend/internal/handler/brand/update_brand_product_handler.go similarity index 100% rename from internal/handler/brand/update_brand_product_handler.go rename to backend/internal/handler/brand/update_brand_product_handler.go diff --git a/internal/handler/brand/upsert_brand_scan_schedule_handler.go b/backend/internal/handler/brand/upsert_brand_scan_schedule_handler.go similarity index 100% rename from internal/handler/brand/upsert_brand_scan_schedule_handler.go rename to backend/internal/handler/brand/upsert_brand_scan_schedule_handler.go diff --git a/internal/handler/copy_mission/create_copy_mission_handler.go b/backend/internal/handler/copy_mission/create_copy_mission_handler.go similarity index 100% rename from internal/handler/copy_mission/create_copy_mission_handler.go rename to backend/internal/handler/copy_mission/create_copy_mission_handler.go diff --git a/internal/handler/copy_mission/delete_copy_mission_handler.go b/backend/internal/handler/copy_mission/delete_copy_mission_handler.go similarity index 100% rename from internal/handler/copy_mission/delete_copy_mission_handler.go rename to backend/internal/handler/copy_mission/delete_copy_mission_handler.go diff --git a/internal/handler/copy_mission/generate_copy_mission_matrix_handler.go b/backend/internal/handler/copy_mission/generate_copy_mission_matrix_handler.go similarity index 100% rename from internal/handler/copy_mission/generate_copy_mission_matrix_handler.go rename to backend/internal/handler/copy_mission/generate_copy_mission_matrix_handler.go diff --git a/internal/handler/copy_mission/get_copy_mission_handler.go b/backend/internal/handler/copy_mission/get_copy_mission_handler.go similarity index 100% rename from internal/handler/copy_mission/get_copy_mission_handler.go rename to backend/internal/handler/copy_mission/get_copy_mission_handler.go diff --git a/internal/handler/copy_mission/get_copy_mission_scan_schedule_handler.go b/backend/internal/handler/copy_mission/get_copy_mission_scan_schedule_handler.go similarity index 100% rename from internal/handler/copy_mission/get_copy_mission_scan_schedule_handler.go rename to backend/internal/handler/copy_mission/get_copy_mission_scan_schedule_handler.go diff --git a/internal/handler/copy_mission/inspire_copy_mission_handler.go b/backend/internal/handler/copy_mission/inspire_copy_mission_handler.go similarity index 100% rename from internal/handler/copy_mission/inspire_copy_mission_handler.go rename to backend/internal/handler/copy_mission/inspire_copy_mission_handler.go diff --git a/internal/handler/copy_mission/list_copy_mission_copy_drafts_handler.go b/backend/internal/handler/copy_mission/list_copy_mission_copy_drafts_handler.go similarity index 100% rename from internal/handler/copy_mission/list_copy_mission_copy_drafts_handler.go rename to backend/internal/handler/copy_mission/list_copy_mission_copy_drafts_handler.go diff --git a/internal/handler/copy_mission/list_copy_mission_scan_posts_handler.go b/backend/internal/handler/copy_mission/list_copy_mission_scan_posts_handler.go similarity index 100% rename from internal/handler/copy_mission/list_copy_mission_scan_posts_handler.go rename to backend/internal/handler/copy_mission/list_copy_mission_scan_posts_handler.go diff --git a/internal/handler/copy_mission/list_copy_missions_handler.go b/backend/internal/handler/copy_mission/list_copy_missions_handler.go similarity index 100% rename from internal/handler/copy_mission/list_copy_missions_handler.go rename to backend/internal/handler/copy_mission/list_copy_missions_handler.go diff --git a/internal/handler/copy_mission/start_copy_mission_analyze_job_handler.go b/backend/internal/handler/copy_mission/start_copy_mission_analyze_job_handler.go similarity index 100% rename from internal/handler/copy_mission/start_copy_mission_analyze_job_handler.go rename to backend/internal/handler/copy_mission/start_copy_mission_analyze_job_handler.go diff --git a/internal/handler/copy_mission/start_copy_mission_copy_draft_job_handler.go b/backend/internal/handler/copy_mission/start_copy_mission_copy_draft_job_handler.go similarity index 100% rename from internal/handler/copy_mission/start_copy_mission_copy_draft_job_handler.go rename to backend/internal/handler/copy_mission/start_copy_mission_copy_draft_job_handler.go diff --git a/internal/handler/copy_mission/start_copy_mission_matrix_job_handler.go b/backend/internal/handler/copy_mission/start_copy_mission_matrix_job_handler.go similarity index 100% rename from internal/handler/copy_mission/start_copy_mission_matrix_job_handler.go rename to backend/internal/handler/copy_mission/start_copy_mission_matrix_job_handler.go diff --git a/internal/handler/copy_mission/start_copy_mission_scan_job_handler.go b/backend/internal/handler/copy_mission/start_copy_mission_scan_job_handler.go similarity index 100% rename from internal/handler/copy_mission/start_copy_mission_scan_job_handler.go rename to backend/internal/handler/copy_mission/start_copy_mission_scan_job_handler.go diff --git a/internal/handler/copy_mission/update_copy_mission_handler.go b/backend/internal/handler/copy_mission/update_copy_mission_handler.go similarity index 100% rename from internal/handler/copy_mission/update_copy_mission_handler.go rename to backend/internal/handler/copy_mission/update_copy_mission_handler.go diff --git a/internal/handler/copy_mission/upsert_copy_mission_scan_schedule_handler.go b/backend/internal/handler/copy_mission/upsert_copy_mission_scan_schedule_handler.go similarity index 100% rename from internal/handler/copy_mission/upsert_copy_mission_scan_schedule_handler.go rename to backend/internal/handler/copy_mission/upsert_copy_mission_scan_schedule_handler.go diff --git a/backend/internal/handler/crm/create_crm_contact_handler.go b/backend/internal/handler/crm/create_crm_contact_handler.go new file mode 100644 index 0000000..52d1aed --- /dev/null +++ b/backend/internal/handler/crm/create_crm_contact_handler.go @@ -0,0 +1,29 @@ +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 CreateCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CreateCrmContactReq + 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 := crm.NewCreateCrmContactLogic(r.Context(), svcCtx) + data, err := l.CreateCrmContact(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/crm/delete_crm_contact_handler.go b/backend/internal/handler/crm/delete_crm_contact_handler.go new file mode 100644 index 0000000..c46723d --- /dev/null +++ b/backend/internal/handler/crm/delete_crm_contact_handler.go @@ -0,0 +1,25 @@ +package crm + +import ( + "net/http" + + "haixun-backend/internal/logic/crm" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func DeleteCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CrmContactIDPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := crm.NewDeleteCrmContactLogic(r.Context(), svcCtx) + err := l.DeleteCrmContact(&req) + response.Write(r.Context(), w, nil, err) + } +} diff --git a/backend/internal/handler/crm/get_crm_contact_handler.go b/backend/internal/handler/crm/get_crm_contact_handler.go new file mode 100644 index 0000000..d4df08c --- /dev/null +++ b/backend/internal/handler/crm/get_crm_contact_handler.go @@ -0,0 +1,25 @@ +package crm + +import ( + "net/http" + + "haixun-backend/internal/logic/crm" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CrmContactIDPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := crm.NewGetCrmContactLogic(r.Context(), svcCtx) + data, err := l.GetCrmContact(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/crm/list_crm_contacts_handler.go b/backend/internal/handler/crm/list_crm_contacts_handler.go new file mode 100644 index 0000000..6d74542 --- /dev/null +++ b/backend/internal/handler/crm/list_crm_contacts_handler.go @@ -0,0 +1,25 @@ +package crm + +import ( + "net/http" + + "haixun-backend/internal/logic/crm" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListCrmContactsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ListCrmContactsReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := crm.NewListCrmContactsLogic(r.Context(), svcCtx) + data, err := l.ListCrmContacts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/crm/update_crm_contact_handler.go b/backend/internal/handler/crm/update_crm_contact_handler.go new file mode 100644 index 0000000..8d2746c --- /dev/null +++ b/backend/internal/handler/crm/update_crm_contact_handler.go @@ -0,0 +1,25 @@ +package crm + +import ( + "net/http" + + "haixun-backend/internal/logic/crm" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func UpdateCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UpdateCrmContactReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := crm.NewUpdateCrmContactLogic(r.Context(), svcCtx) + data, err := l.UpdateCrmContact(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/job/ack_worker_job_cancel_handler.go b/backend/internal/handler/job/ack_worker_job_cancel_handler.go similarity index 100% rename from internal/handler/job/ack_worker_job_cancel_handler.go rename to backend/internal/handler/job/ack_worker_job_cancel_handler.go diff --git a/internal/handler/job/analyze_style8_d_from_worker_handler.go b/backend/internal/handler/job/analyze_style8_d_from_worker_handler.go similarity index 100% rename from internal/handler/job/analyze_style8_d_from_worker_handler.go rename to backend/internal/handler/job/analyze_style8_d_from_worker_handler.go diff --git a/internal/handler/job/cancel_job_handler.go b/backend/internal/handler/job/cancel_job_handler.go similarity index 100% rename from internal/handler/job/cancel_job_handler.go rename to backend/internal/handler/job/cancel_job_handler.go diff --git a/internal/handler/job/check_worker_job_cancel_handler.go b/backend/internal/handler/job/check_worker_job_cancel_handler.go similarity index 100% rename from internal/handler/job/check_worker_job_cancel_handler.go rename to backend/internal/handler/job/check_worker_job_cancel_handler.go diff --git a/internal/handler/job/claim_worker_job_handler.go b/backend/internal/handler/job/claim_worker_job_handler.go similarity index 100% rename from internal/handler/job/claim_worker_job_handler.go rename to backend/internal/handler/job/claim_worker_job_handler.go diff --git a/internal/handler/job/complete_worker_job_handler.go b/backend/internal/handler/job/complete_worker_job_handler.go similarity index 100% rename from internal/handler/job/complete_worker_job_handler.go rename to backend/internal/handler/job/complete_worker_job_handler.go diff --git a/internal/handler/job/create_job_handler.go b/backend/internal/handler/job/create_job_handler.go similarity index 100% rename from internal/handler/job/create_job_handler.go rename to backend/internal/handler/job/create_job_handler.go diff --git a/internal/handler/job/create_job_schedule_handler.go b/backend/internal/handler/job/create_job_schedule_handler.go similarity index 100% rename from internal/handler/job/create_job_schedule_handler.go rename to backend/internal/handler/job/create_job_schedule_handler.go diff --git a/internal/handler/job/delete_job_schedule_handler.go b/backend/internal/handler/job/delete_job_schedule_handler.go similarity index 100% rename from internal/handler/job/delete_job_schedule_handler.go rename to backend/internal/handler/job/delete_job_schedule_handler.go diff --git a/internal/handler/job/disable_job_schedule_handler.go b/backend/internal/handler/job/disable_job_schedule_handler.go similarity index 100% rename from internal/handler/job/disable_job_schedule_handler.go rename to backend/internal/handler/job/disable_job_schedule_handler.go diff --git a/internal/handler/job/enable_job_schedule_handler.go b/backend/internal/handler/job/enable_job_schedule_handler.go similarity index 100% rename from internal/handler/job/enable_job_schedule_handler.go rename to backend/internal/handler/job/enable_job_schedule_handler.go diff --git a/internal/handler/job/fail_worker_job_handler.go b/backend/internal/handler/job/fail_worker_job_handler.go similarity index 100% rename from internal/handler/job/fail_worker_job_handler.go rename to backend/internal/handler/job/fail_worker_job_handler.go diff --git a/internal/handler/job/get_job_handler.go b/backend/internal/handler/job/get_job_handler.go similarity index 100% rename from internal/handler/job/get_job_handler.go rename to backend/internal/handler/job/get_job_handler.go diff --git a/internal/handler/job/get_job_template_handler.go b/backend/internal/handler/job/get_job_template_handler.go similarity index 100% rename from internal/handler/job/get_job_template_handler.go rename to backend/internal/handler/job/get_job_template_handler.go diff --git a/internal/handler/job/get_worker_threads_account_session_handler.go b/backend/internal/handler/job/get_worker_threads_account_session_handler.go similarity index 100% rename from internal/handler/job/get_worker_threads_account_session_handler.go rename to backend/internal/handler/job/get_worker_threads_account_session_handler.go diff --git a/internal/handler/job/list_job_events_handler.go b/backend/internal/handler/job/list_job_events_handler.go similarity index 100% rename from internal/handler/job/list_job_events_handler.go rename to backend/internal/handler/job/list_job_events_handler.go diff --git a/internal/handler/job/list_job_schedules_handler.go b/backend/internal/handler/job/list_job_schedules_handler.go similarity index 100% rename from internal/handler/job/list_job_schedules_handler.go rename to backend/internal/handler/job/list_job_schedules_handler.go diff --git a/internal/handler/job/list_job_templates_handler.go b/backend/internal/handler/job/list_job_templates_handler.go similarity index 100% rename from internal/handler/job/list_job_templates_handler.go rename to backend/internal/handler/job/list_job_templates_handler.go diff --git a/internal/handler/job/list_jobs_handler.go b/backend/internal/handler/job/list_jobs_handler.go similarity index 100% rename from internal/handler/job/list_jobs_handler.go rename to backend/internal/handler/job/list_jobs_handler.go diff --git a/internal/handler/job/refresh_worker_job_lock_handler.go b/backend/internal/handler/job/refresh_worker_job_lock_handler.go similarity index 100% rename from internal/handler/job/refresh_worker_job_lock_handler.go rename to backend/internal/handler/job/refresh_worker_job_lock_handler.go diff --git a/internal/handler/job/retry_job_handler.go b/backend/internal/handler/job/retry_job_handler.go similarity index 100% rename from internal/handler/job/retry_job_handler.go rename to backend/internal/handler/job/retry_job_handler.go diff --git a/internal/handler/job/store_persona_style_profile_from_worker_handler.go b/backend/internal/handler/job/store_persona_style_profile_from_worker_handler.go similarity index 100% rename from internal/handler/job/store_persona_style_profile_from_worker_handler.go rename to backend/internal/handler/job/store_persona_style_profile_from_worker_handler.go diff --git a/internal/handler/job/update_job_schedule_handler.go b/backend/internal/handler/job/update_job_schedule_handler.go similarity index 100% rename from internal/handler/job/update_job_schedule_handler.go rename to backend/internal/handler/job/update_job_schedule_handler.go diff --git a/internal/handler/job/update_worker_job_progress_handler.go b/backend/internal/handler/job/update_worker_job_progress_handler.go similarity index 100% rename from internal/handler/job/update_worker_job_progress_handler.go rename to backend/internal/handler/job/update_worker_job_progress_handler.go diff --git a/internal/handler/job/upsert_job_template_handler.go b/backend/internal/handler/job/upsert_job_template_handler.go similarity index 100% rename from internal/handler/job/upsert_job_template_handler.go rename to backend/internal/handler/job/upsert_job_template_handler.go diff --git a/internal/handler/member/get_member_me_handler.go b/backend/internal/handler/member/get_member_me_handler.go similarity index 100% rename from internal/handler/member/get_member_me_handler.go rename to backend/internal/handler/member/get_member_me_handler.go diff --git a/internal/handler/member/get_member_placement_settings_handler.go b/backend/internal/handler/member/get_member_placement_settings_handler.go similarity index 100% rename from internal/handler/member/get_member_placement_settings_handler.go rename to backend/internal/handler/member/get_member_placement_settings_handler.go diff --git a/internal/handler/member/update_member_me_handler.go b/backend/internal/handler/member/update_member_me_handler.go similarity index 100% rename from internal/handler/member/update_member_me_handler.go rename to backend/internal/handler/member/update_member_me_handler.go diff --git a/internal/handler/member/update_member_placement_settings_handler.go b/backend/internal/handler/member/update_member_placement_settings_handler.go similarity index 100% rename from internal/handler/member/update_member_placement_settings_handler.go rename to backend/internal/handler/member/update_member_placement_settings_handler.go diff --git a/internal/handler/normal/health_handler.go b/backend/internal/handler/normal/health_handler.go similarity index 100% rename from internal/handler/normal/health_handler.go rename to backend/internal/handler/normal/health_handler.go diff --git a/internal/handler/permission/get_me_permissions_handler.go b/backend/internal/handler/permission/get_me_permissions_handler.go similarity index 100% rename from internal/handler/permission/get_me_permissions_handler.go rename to backend/internal/handler/permission/get_me_permissions_handler.go diff --git a/internal/handler/permission/get_permission_catalog_handler.go b/backend/internal/handler/permission/get_permission_catalog_handler.go similarity index 100% rename from internal/handler/permission/get_permission_catalog_handler.go rename to backend/internal/handler/permission/get_permission_catalog_handler.go diff --git a/internal/handler/persona/create_persona_handler.go b/backend/internal/handler/persona/create_persona_handler.go similarity index 100% rename from internal/handler/persona/create_persona_handler.go rename to backend/internal/handler/persona/create_persona_handler.go diff --git a/internal/handler/persona/delete_persona_handler.go b/backend/internal/handler/persona/delete_persona_handler.go similarity index 100% rename from internal/handler/persona/delete_persona_handler.go rename to backend/internal/handler/persona/delete_persona_handler.go diff --git a/internal/handler/persona/generate_persona_copy_draft_handler.go b/backend/internal/handler/persona/generate_persona_copy_draft_handler.go similarity index 100% rename from internal/handler/persona/generate_persona_copy_draft_handler.go rename to backend/internal/handler/persona/generate_persona_copy_draft_handler.go diff --git a/internal/handler/persona/get_persona_handler.go b/backend/internal/handler/persona/get_persona_handler.go similarity index 100% rename from internal/handler/persona/get_persona_handler.go rename to backend/internal/handler/persona/get_persona_handler.go diff --git a/internal/handler/persona/list_persona_copy_drafts_handler.go b/backend/internal/handler/persona/list_persona_copy_drafts_handler.go similarity index 100% rename from internal/handler/persona/list_persona_copy_drafts_handler.go rename to backend/internal/handler/persona/list_persona_copy_drafts_handler.go diff --git a/internal/handler/persona/list_persona_viral_scan_posts_handler.go b/backend/internal/handler/persona/list_persona_viral_scan_posts_handler.go similarity index 100% rename from internal/handler/persona/list_persona_viral_scan_posts_handler.go rename to backend/internal/handler/persona/list_persona_viral_scan_posts_handler.go diff --git a/internal/handler/persona/list_personas_handler.go b/backend/internal/handler/persona/list_personas_handler.go similarity index 100% rename from internal/handler/persona/list_personas_handler.go rename to backend/internal/handler/persona/list_personas_handler.go diff --git a/internal/handler/persona/publish_persona_copy_draft_handler.go b/backend/internal/handler/persona/publish_persona_copy_draft_handler.go similarity index 100% rename from internal/handler/persona/publish_persona_copy_draft_handler.go rename to backend/internal/handler/persona/publish_persona_copy_draft_handler.go diff --git a/internal/handler/persona/start_persona_style_analysis_handler.go b/backend/internal/handler/persona/start_persona_style_analysis_handler.go similarity index 100% rename from internal/handler/persona/start_persona_style_analysis_handler.go rename to backend/internal/handler/persona/start_persona_style_analysis_handler.go diff --git a/internal/handler/persona/start_persona_viral_scan_job_handler.go b/backend/internal/handler/persona/start_persona_viral_scan_job_handler.go similarity index 100% rename from internal/handler/persona/start_persona_viral_scan_job_handler.go rename to backend/internal/handler/persona/start_persona_viral_scan_job_handler.go diff --git a/internal/handler/persona/update_persona_copy_draft_handler.go b/backend/internal/handler/persona/update_persona_copy_draft_handler.go similarity index 100% rename from internal/handler/persona/update_persona_copy_draft_handler.go rename to backend/internal/handler/persona/update_persona_copy_draft_handler.go diff --git a/internal/handler/persona/update_persona_handler.go b/backend/internal/handler/persona/update_persona_handler.go similarity index 100% rename from internal/handler/persona/update_persona_handler.go rename to backend/internal/handler/persona/update_persona_handler.go diff --git a/internal/handler/placement_topic/batch_delete_placement_topic_scan_posts_handler.go b/backend/internal/handler/placement_topic/batch_delete_placement_topic_scan_posts_handler.go similarity index 100% rename from internal/handler/placement_topic/batch_delete_placement_topic_scan_posts_handler.go rename to backend/internal/handler/placement_topic/batch_delete_placement_topic_scan_posts_handler.go diff --git a/internal/handler/placement_topic/create_placement_topic_handler.go b/backend/internal/handler/placement_topic/create_placement_topic_handler.go similarity index 100% rename from internal/handler/placement_topic/create_placement_topic_handler.go rename to backend/internal/handler/placement_topic/create_placement_topic_handler.go diff --git a/internal/handler/placement_topic/delete_placement_topic_handler.go b/backend/internal/handler/placement_topic/delete_placement_topic_handler.go similarity index 100% rename from internal/handler/placement_topic/delete_placement_topic_handler.go rename to backend/internal/handler/placement_topic/delete_placement_topic_handler.go diff --git a/internal/handler/placement_topic/delete_placement_topic_scan_post_handler.go b/backend/internal/handler/placement_topic/delete_placement_topic_scan_post_handler.go similarity index 100% rename from internal/handler/placement_topic/delete_placement_topic_scan_post_handler.go rename to backend/internal/handler/placement_topic/delete_placement_topic_scan_post_handler.go diff --git a/internal/handler/placement_topic/expand_placement_topic_graph_handler.go b/backend/internal/handler/placement_topic/expand_placement_topic_graph_handler.go similarity index 100% rename from internal/handler/placement_topic/expand_placement_topic_graph_handler.go rename to backend/internal/handler/placement_topic/expand_placement_topic_graph_handler.go diff --git a/internal/handler/placement_topic/generate_placement_topic_content_matrix_handler.go b/backend/internal/handler/placement_topic/generate_placement_topic_content_matrix_handler.go similarity index 100% rename from internal/handler/placement_topic/generate_placement_topic_content_matrix_handler.go rename to backend/internal/handler/placement_topic/generate_placement_topic_content_matrix_handler.go diff --git a/internal/handler/placement_topic/generate_placement_topic_outreach_drafts_handler.go b/backend/internal/handler/placement_topic/generate_placement_topic_outreach_drafts_handler.go similarity index 100% rename from internal/handler/placement_topic/generate_placement_topic_outreach_drafts_handler.go rename to backend/internal/handler/placement_topic/generate_placement_topic_outreach_drafts_handler.go diff --git a/internal/handler/placement_topic/get_placement_topic_content_matrix_handler.go b/backend/internal/handler/placement_topic/get_placement_topic_content_matrix_handler.go similarity index 100% rename from internal/handler/placement_topic/get_placement_topic_content_matrix_handler.go rename to backend/internal/handler/placement_topic/get_placement_topic_content_matrix_handler.go diff --git a/internal/handler/placement_topic/get_placement_topic_graph_handler.go b/backend/internal/handler/placement_topic/get_placement_topic_graph_handler.go similarity index 100% rename from internal/handler/placement_topic/get_placement_topic_graph_handler.go rename to backend/internal/handler/placement_topic/get_placement_topic_graph_handler.go diff --git a/internal/handler/placement_topic/get_placement_topic_handler.go b/backend/internal/handler/placement_topic/get_placement_topic_handler.go similarity index 100% rename from internal/handler/placement_topic/get_placement_topic_handler.go rename to backend/internal/handler/placement_topic/get_placement_topic_handler.go diff --git a/internal/handler/placement_topic/get_placement_topic_scan_schedule_handler.go b/backend/internal/handler/placement_topic/get_placement_topic_scan_schedule_handler.go similarity index 100% rename from internal/handler/placement_topic/get_placement_topic_scan_schedule_handler.go rename to backend/internal/handler/placement_topic/get_placement_topic_scan_schedule_handler.go diff --git a/internal/handler/placement_topic/list_placement_topic_scan_posts_handler.go b/backend/internal/handler/placement_topic/list_placement_topic_scan_posts_handler.go similarity index 100% rename from internal/handler/placement_topic/list_placement_topic_scan_posts_handler.go rename to backend/internal/handler/placement_topic/list_placement_topic_scan_posts_handler.go diff --git a/internal/handler/placement_topic/list_placement_topics_handler.go b/backend/internal/handler/placement_topic/list_placement_topics_handler.go similarity index 100% rename from internal/handler/placement_topic/list_placement_topics_handler.go rename to backend/internal/handler/placement_topic/list_placement_topics_handler.go diff --git a/internal/handler/placement_topic/patch_placement_topic_graph_nodes_handler.go b/backend/internal/handler/placement_topic/patch_placement_topic_graph_nodes_handler.go similarity index 100% rename from internal/handler/placement_topic/patch_placement_topic_graph_nodes_handler.go rename to backend/internal/handler/placement_topic/patch_placement_topic_graph_nodes_handler.go diff --git a/internal/handler/placement_topic/patch_placement_topic_scan_post_outreach_handler.go b/backend/internal/handler/placement_topic/patch_placement_topic_scan_post_outreach_handler.go similarity index 100% rename from internal/handler/placement_topic/patch_placement_topic_scan_post_outreach_handler.go rename to backend/internal/handler/placement_topic/patch_placement_topic_scan_post_outreach_handler.go diff --git a/internal/handler/placement_topic/publish_placement_topic_outreach_draft_handler.go b/backend/internal/handler/placement_topic/publish_placement_topic_outreach_draft_handler.go similarity index 100% rename from internal/handler/placement_topic/publish_placement_topic_outreach_draft_handler.go rename to backend/internal/handler/placement_topic/publish_placement_topic_outreach_draft_handler.go diff --git a/internal/handler/placement_topic/start_placement_topic_scan_job_handler.go b/backend/internal/handler/placement_topic/start_placement_topic_scan_job_handler.go similarity index 100% rename from internal/handler/placement_topic/start_placement_topic_scan_job_handler.go rename to backend/internal/handler/placement_topic/start_placement_topic_scan_job_handler.go diff --git a/internal/handler/placement_topic/update_placement_topic_handler.go b/backend/internal/handler/placement_topic/update_placement_topic_handler.go similarity index 100% rename from internal/handler/placement_topic/update_placement_topic_handler.go rename to backend/internal/handler/placement_topic/update_placement_topic_handler.go diff --git a/internal/handler/placement_topic/upsert_placement_topic_scan_schedule_handler.go b/backend/internal/handler/placement_topic/upsert_placement_topic_scan_schedule_handler.go similarity index 100% rename from internal/handler/placement_topic/upsert_placement_topic_scan_schedule_handler.go rename to backend/internal/handler/placement_topic/upsert_placement_topic_scan_schedule_handler.go diff --git a/internal/handler/routes.go b/backend/internal/handler/routes.go similarity index 93% rename from internal/handler/routes.go rename to backend/internal/handler/routes.go index df1dd67..a2a8e3e 100644 --- a/internal/handler/routes.go +++ b/backend/internal/handler/routes.go @@ -10,12 +10,14 @@ 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" @@ -710,6 +712,54 @@ 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}, @@ -749,6 +799,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:id/ai-settings", Handler: threads_account.UpdateThreadsAccountAiSettingsHandler(serverCtx), }, + { + Method: http.MethodPost, + Path: "/:id/ai-settings/providers/:provider/models", + Handler: threads_account.ListThreadsAccountAiProviderModelsHandler(serverCtx), + }, { Method: http.MethodGet, Path: "/:id/connection", diff --git a/backend/internal/handler/scan_post/lookalike_handler.go b/backend/internal/handler/scan_post/lookalike_handler.go new file mode 100644 index 0000000..5123c62 --- /dev/null +++ b/backend/internal/handler/scan_post/lookalike_handler.go @@ -0,0 +1,29 @@ +package scan_post + +import ( + "net/http" + + "haixun-backend/internal/logic/scan_post" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func LookalikeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.LookalikeReq + 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 := scan_post.NewLookalikeLogic(r.Context(), svcCtx) + data, err := l.Lookalike(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/setting/delete_setting_handler.go b/backend/internal/handler/setting/delete_setting_handler.go similarity index 100% rename from internal/handler/setting/delete_setting_handler.go rename to backend/internal/handler/setting/delete_setting_handler.go diff --git a/internal/handler/setting/get_setting_handler.go b/backend/internal/handler/setting/get_setting_handler.go similarity index 100% rename from internal/handler/setting/get_setting_handler.go rename to backend/internal/handler/setting/get_setting_handler.go diff --git a/internal/handler/setting/list_settings_handler.go b/backend/internal/handler/setting/list_settings_handler.go similarity index 100% rename from internal/handler/setting/list_settings_handler.go rename to backend/internal/handler/setting/list_settings_handler.go diff --git a/internal/handler/setting/upsert_setting_handler.go b/backend/internal/handler/setting/upsert_setting_handler.go similarity index 100% rename from internal/handler/setting/upsert_setting_handler.go rename to backend/internal/handler/setting/upsert_setting_handler.go diff --git a/internal/handler/threads_account/activate_threads_account_handler.go b/backend/internal/handler/threads_account/activate_threads_account_handler.go similarity index 100% rename from internal/handler/threads_account/activate_threads_account_handler.go rename to backend/internal/handler/threads_account/activate_threads_account_handler.go diff --git a/internal/handler/threads_account/create_threads_account_handler.go b/backend/internal/handler/threads_account/create_threads_account_handler.go similarity index 100% rename from internal/handler/threads_account/create_threads_account_handler.go rename to backend/internal/handler/threads_account/create_threads_account_handler.go diff --git a/internal/handler/threads_account/get_threads_account_ai_settings_handler.go b/backend/internal/handler/threads_account/get_threads_account_ai_settings_handler.go similarity index 100% rename from internal/handler/threads_account/get_threads_account_ai_settings_handler.go rename to backend/internal/handler/threads_account/get_threads_account_ai_settings_handler.go diff --git a/internal/handler/threads_account/get_threads_account_connection_handler.go b/backend/internal/handler/threads_account/get_threads_account_connection_handler.go similarity index 100% rename from internal/handler/threads_account/get_threads_account_connection_handler.go rename to backend/internal/handler/threads_account/get_threads_account_connection_handler.go diff --git a/internal/handler/threads_account/get_threads_account_handler.go b/backend/internal/handler/threads_account/get_threads_account_handler.go similarity index 100% rename from internal/handler/threads_account/get_threads_account_handler.go rename to backend/internal/handler/threads_account/get_threads_account_handler.go diff --git a/internal/handler/threads_account/import_threads_account_session_handler.go b/backend/internal/handler/threads_account/import_threads_account_session_handler.go similarity index 100% rename from internal/handler/threads_account/import_threads_account_session_handler.go rename to backend/internal/handler/threads_account/import_threads_account_session_handler.go diff --git a/backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go b/backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go new file mode 100644 index 0000000..e7ebf6a --- /dev/null +++ b/backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go @@ -0,0 +1,30 @@ +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 ListThreadsAccountAiProviderModelsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsAccountAiProviderModelsHandlerReq + 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.NewListThreadsAccountAiProviderModelsLogic(r.Context(), svcCtx) + data, err := l.ListThreadsAccountAiProviderModels(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/threads_account/list_threads_accounts_handler.go b/backend/internal/handler/threads_account/list_threads_accounts_handler.go similarity index 100% rename from internal/handler/threads_account/list_threads_accounts_handler.go rename to backend/internal/handler/threads_account/list_threads_accounts_handler.go diff --git a/internal/handler/threads_account/update_threads_account_ai_settings_handler.go b/backend/internal/handler/threads_account/update_threads_account_ai_settings_handler.go similarity index 100% rename from internal/handler/threads_account/update_threads_account_ai_settings_handler.go rename to backend/internal/handler/threads_account/update_threads_account_ai_settings_handler.go diff --git a/internal/handler/threads_account/update_threads_account_connection_handler.go b/backend/internal/handler/threads_account/update_threads_account_connection_handler.go similarity index 100% rename from internal/handler/threads_account/update_threads_account_connection_handler.go rename to backend/internal/handler/threads_account/update_threads_account_connection_handler.go diff --git a/internal/handler/threads_account/update_threads_account_handler.go b/backend/internal/handler/threads_account/update_threads_account_handler.go similarity index 100% rename from internal/handler/threads_account/update_threads_account_handler.go rename to backend/internal/handler/threads_account/update_threads_account_handler.go diff --git a/internal/library/authctx/context.go b/backend/internal/library/authctx/context.go similarity index 100% rename from internal/library/authctx/context.go rename to backend/internal/library/authctx/context.go diff --git a/internal/library/brave/breaker.go b/backend/internal/library/brave/breaker.go similarity index 100% rename from internal/library/brave/breaker.go rename to backend/internal/library/brave/breaker.go diff --git a/internal/library/brave/client.go b/backend/internal/library/brave/client.go similarity index 100% rename from internal/library/brave/client.go rename to backend/internal/library/brave/client.go diff --git a/internal/library/clock/clock.go b/backend/internal/library/clock/clock.go similarity index 100% rename from internal/library/clock/clock.go rename to backend/internal/library/clock/clock.go diff --git a/internal/library/clock/clock_test.go b/backend/internal/library/clock/clock_test.go similarity index 100% rename from internal/library/clock/clock_test.go rename to backend/internal/library/clock/clock_test.go diff --git a/backend/internal/library/crypto/aesgcm.go b/backend/internal/library/crypto/aesgcm.go new file mode 100644 index 0000000..d802417 --- /dev/null +++ b/backend/internal/library/crypto/aesgcm.go @@ -0,0 +1,99 @@ +// Package crypto provides application-layer encryption for sensitive data at +// rest (browser session storage, third-party API keys). +// +// Ciphertext format: "enc:v1:" + base64url(nonce || ciphertext||tag). +// Values without the prefix are treated as legacy plaintext and returned as-is +// on Decrypt, so enabling encryption is backward compatible with existing data. +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "strings" +) + +const cipherPrefix = "enc:v1:" + +// Cipher encrypts/decrypts secret strings. When built without a key it is +// disabled and acts as a passthrough (for local dev), but Decrypt still +// transparently handles previously written ciphertext if any. +type Cipher struct { + aead cipher.AEAD +} + +// New builds a Cipher from a base64-encoded 32-byte (AES-256) key. +// An empty key returns a disabled (passthrough) cipher. +func New(base64Key string) (*Cipher, error) { + base64Key = strings.TrimSpace(base64Key) + if base64Key == "" { + return &Cipher{}, nil + } + key, err := base64.StdEncoding.DecodeString(base64Key) + if err != nil { + return nil, fmt.Errorf("crypto: invalid base64 encryption key: %w", err) + } + if len(key) != 32 { + return nil, fmt.Errorf("crypto: encryption key must be 32 bytes (got %d)", len(key)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("crypto: new cipher: %w", err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("crypto: new gcm: %w", err) + } + return &Cipher{aead: aead}, nil +} + +// Enabled reports whether a key is configured. +func (c *Cipher) Enabled() bool { + return c != nil && c.aead != nil +} + +// Encrypt returns ciphertext for non-empty plaintext when enabled; otherwise +// it returns the input unchanged. +func (c *Cipher) Encrypt(plaintext string) (string, error) { + if !c.Enabled() || plaintext == "" { + return plaintext, nil + } + if strings.HasPrefix(plaintext, cipherPrefix) { + // Already encrypted; avoid double-encrypting. + return plaintext, nil + } + nonce := make([]byte, c.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", fmt.Errorf("crypto: read nonce: %w", err) + } + sealed := c.aead.Seal(nonce, nonce, []byte(plaintext), nil) + return cipherPrefix + base64.RawURLEncoding.EncodeToString(sealed), nil +} + +// Decrypt reverses Encrypt. Values without the cipher prefix are returned +// unchanged (legacy plaintext), keeping the change backward compatible. +func (c *Cipher) Decrypt(value string) (string, error) { + if !strings.HasPrefix(value, cipherPrefix) { + return value, nil + } + if !c.Enabled() { + return "", errors.New("crypto: encrypted value found but no encryption key configured") + } + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(value, cipherPrefix)) + if err != nil { + return "", fmt.Errorf("crypto: decode ciphertext: %w", err) + } + nonceSize := c.aead.NonceSize() + if len(raw) < nonceSize { + return "", errors.New("crypto: ciphertext too short") + } + nonce, ct := raw[:nonceSize], raw[nonceSize:] + plain, err := c.aead.Open(nil, nonce, ct, nil) + if err != nil { + return "", fmt.Errorf("crypto: decrypt: %w", err) + } + return string(plain), nil +} diff --git a/backend/internal/library/embedding/client.go b/backend/internal/library/embedding/client.go new file mode 100644 index 0000000..097d8df --- /dev/null +++ b/backend/internal/library/embedding/client.go @@ -0,0 +1,135 @@ +package embedding + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "strings" +) + +const DefaultModel = "text-embedding-3-small" + +type Client struct { + apiKey string + model string + http *http.Client +} + +func NewClient(apiKey string) *Client { + return &Client{ + apiKey: strings.TrimSpace(apiKey), + model: DefaultModel, + http: &http.Client{}, + } +} + +func (c *Client) Enabled() bool { + return c != nil && c.apiKey != "" +} + +type EmbeddingRequest struct { + Input []string `json:"input"` + Model string `json:"model"` +} + +type EmbeddingResponse struct { + Data []struct { + Embedding []float32 `json:"embedding"` + } `json:"data"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` + Usage *struct { + TotalTokens int `json:"total_tokens"` + } `json:"usage,omitempty"` +} + +func (c *Client) Embed(ctx context.Context, input string) ([]float32, error) { + if !c.Enabled() { + return nil, fmt.Errorf("embedding client not configured") + } + return c.EmbedBatch(ctx, []string{input}) +} + +func (c *Client) EmbedBatch(ctx context.Context, inputs []string) ([]float32, error) { + if !c.Enabled() { + return nil, fmt.Errorf("embedding client not configured") + } + if len(inputs) == 0 { + return nil, fmt.Errorf("no input text") + } + + req := EmbeddingRequest{ + Input: inputs, + Model: c.model, + } + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.openai.com/v1/embeddings", bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + var errResp struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal(respBody, &errResp) == nil && errResp.Error.Message != "" { + return nil, fmt.Errorf("openai embedding: %s", errResp.Error.Message) + } + return nil, fmt.Errorf("openai embedding: http %d", resp.StatusCode) + } + + var result EmbeddingResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, err + } + + if result.Error != nil && result.Error.Message != "" { + return nil, fmt.Errorf("openai embedding: %s", result.Error.Message) + } + + if len(result.Data) == 0 { + return nil, fmt.Errorf("openai embedding: no data returned") + } + + return result.Data[0].Embedding, nil +} + +func CosineSimilarity(a, b []float32) float32 { + if len(a) == 0 || len(b) == 0 || len(a) != len(b) { + return 0 + } + var dot, normA, normB float64 + for i := 0; i < len(a); i++ { + dot += float64(a[i] * b[i]) + normA += float64(a[i] * a[i]) + normB += float64(b[i] * b[i]) + } + if normA == 0 || normB == 0 { + return 0 + } + return float32(dot / (math.Sqrt(normA) * math.Sqrt(normB))) +} diff --git a/internal/library/errors/code/types.go b/backend/internal/library/errors/code/types.go similarity index 100% rename from internal/library/errors/code/types.go rename to backend/internal/library/errors/code/types.go diff --git a/internal/library/errors/errors.go b/backend/internal/library/errors/errors.go similarity index 100% rename from internal/library/errors/errors.go rename to backend/internal/library/errors/errors.go diff --git a/internal/library/exa/client.go b/backend/internal/library/exa/client.go similarity index 100% rename from internal/library/exa/client.go rename to backend/internal/library/exa/client.go diff --git a/internal/library/exa/client_test.go b/backend/internal/library/exa/client_test.go similarity index 100% rename from internal/library/exa/client_test.go rename to backend/internal/library/exa/client_test.go diff --git a/internal/library/knowledge/bootstrap.go b/backend/internal/library/knowledge/bootstrap.go similarity index 100% rename from internal/library/knowledge/bootstrap.go rename to backend/internal/library/knowledge/bootstrap.go diff --git a/internal/library/knowledge/bootstrap_test.go b/backend/internal/library/knowledge/bootstrap_test.go similarity index 100% rename from internal/library/knowledge/bootstrap_test.go rename to backend/internal/library/knowledge/bootstrap_test.go diff --git a/internal/library/knowledge/brave_collect.go b/backend/internal/library/knowledge/brave_collect.go similarity index 100% rename from internal/library/knowledge/brave_collect.go rename to backend/internal/library/knowledge/brave_collect.go diff --git a/internal/library/knowledge/brave_collect_test.go b/backend/internal/library/knowledge/brave_collect_test.go similarity index 100% rename from internal/library/knowledge/brave_collect_test.go rename to backend/internal/library/knowledge/brave_collect_test.go diff --git a/internal/library/knowledge/expand_strategy.go b/backend/internal/library/knowledge/expand_strategy.go similarity index 100% rename from internal/library/knowledge/expand_strategy.go rename to backend/internal/library/knowledge/expand_strategy.go diff --git a/internal/library/knowledge/expand_strategy_test.go b/backend/internal/library/knowledge/expand_strategy_test.go similarity index 100% rename from internal/library/knowledge/expand_strategy_test.go rename to backend/internal/library/knowledge/expand_strategy_test.go diff --git a/internal/library/knowledge/graph.go b/backend/internal/library/knowledge/graph.go similarity index 100% rename from internal/library/knowledge/graph.go rename to backend/internal/library/knowledge/graph.go diff --git a/internal/library/knowledge/patrol_input.go b/backend/internal/library/knowledge/patrol_input.go similarity index 100% rename from internal/library/knowledge/patrol_input.go rename to backend/internal/library/knowledge/patrol_input.go diff --git a/internal/library/knowledge/patrol_phrase.go b/backend/internal/library/knowledge/patrol_phrase.go similarity index 100% rename from internal/library/knowledge/patrol_phrase.go rename to backend/internal/library/knowledge/patrol_phrase.go diff --git a/internal/library/knowledge/patrol_phrase_test.go b/backend/internal/library/knowledge/patrol_phrase_test.go similarity index 100% rename from internal/library/knowledge/patrol_phrase_test.go rename to backend/internal/library/knowledge/patrol_phrase_test.go diff --git a/internal/library/knowledge/patrol_rank.go b/backend/internal/library/knowledge/patrol_rank.go similarity index 100% rename from internal/library/knowledge/patrol_rank.go rename to backend/internal/library/knowledge/patrol_rank.go diff --git a/internal/library/knowledge/patrol_rank_test.go b/backend/internal/library/knowledge/patrol_rank_test.go similarity index 100% rename from internal/library/knowledge/patrol_rank_test.go rename to backend/internal/library/knowledge/patrol_rank_test.go diff --git a/internal/library/knowledge/patrol_resolve.go b/backend/internal/library/knowledge/patrol_resolve.go similarity index 100% rename from internal/library/knowledge/patrol_resolve.go rename to backend/internal/library/knowledge/patrol_resolve.go diff --git a/internal/library/knowledge/patrol_resolve_test.go b/backend/internal/library/knowledge/patrol_resolve_test.go similarity index 100% rename from internal/library/knowledge/patrol_resolve_test.go rename to backend/internal/library/knowledge/patrol_resolve_test.go diff --git a/internal/library/knowledge/patrol_tags.go b/backend/internal/library/knowledge/patrol_tags.go similarity index 100% rename from internal/library/knowledge/patrol_tags.go rename to backend/internal/library/knowledge/patrol_tags.go diff --git a/internal/library/knowledge/patrol_tags_test.go b/backend/internal/library/knowledge/patrol_tags_test.go similarity index 100% rename from internal/library/knowledge/patrol_tags_test.go rename to backend/internal/library/knowledge/patrol_tags_test.go diff --git a/internal/library/knowledge/quality.go b/backend/internal/library/knowledge/quality.go similarity index 100% rename from internal/library/knowledge/quality.go rename to backend/internal/library/knowledge/quality.go diff --git a/internal/library/knowledge/quality_test.go b/backend/internal/library/knowledge/quality_test.go similarity index 100% rename from internal/library/knowledge/quality_test.go rename to backend/internal/library/knowledge/quality_test.go diff --git a/internal/library/knowledge/queries.go b/backend/internal/library/knowledge/queries.go similarity index 100% rename from internal/library/knowledge/queries.go rename to backend/internal/library/knowledge/queries.go diff --git a/internal/library/knowledge/queries_test.go b/backend/internal/library/knowledge/queries_test.go similarity index 100% rename from internal/library/knowledge/queries_test.go rename to backend/internal/library/knowledge/queries_test.go diff --git a/internal/library/knowledge/synth.go b/backend/internal/library/knowledge/synth.go similarity index 100% rename from internal/library/knowledge/synth.go rename to backend/internal/library/knowledge/synth.go diff --git a/internal/library/knowledge/synth_test.go b/backend/internal/library/knowledge/synth_test.go similarity index 100% rename from internal/library/knowledge/synth_test.go rename to backend/internal/library/knowledge/synth_test.go diff --git a/internal/library/matrix/copy_generate.go b/backend/internal/library/matrix/copy_generate.go similarity index 100% rename from internal/library/matrix/copy_generate.go rename to backend/internal/library/matrix/copy_generate.go diff --git a/internal/library/matrix/generate.go b/backend/internal/library/matrix/generate.go similarity index 100% rename from internal/library/matrix/generate.go rename to backend/internal/library/matrix/generate.go diff --git a/internal/library/matrix/samples.go b/backend/internal/library/matrix/samples.go similarity index 100% rename from internal/library/matrix/samples.go rename to backend/internal/library/matrix/samples.go diff --git a/internal/library/mongo/brand_scope.go b/backend/internal/library/mongo/brand_scope.go similarity index 100% rename from internal/library/mongo/brand_scope.go rename to backend/internal/library/mongo/brand_scope.go diff --git a/internal/library/mongo/client.go b/backend/internal/library/mongo/client.go similarity index 100% rename from internal/library/mongo/client.go rename to backend/internal/library/mongo/client.go diff --git a/internal/library/mongo/ensure_index.go b/backend/internal/library/mongo/ensure_index.go similarity index 100% rename from internal/library/mongo/ensure_index.go rename to backend/internal/library/mongo/ensure_index.go diff --git a/internal/library/outreach/generate.go b/backend/internal/library/outreach/generate.go similarity index 100% rename from internal/library/outreach/generate.go rename to backend/internal/library/outreach/generate.go diff --git a/internal/library/outreach/generate_test.go b/backend/internal/library/outreach/generate_test.go similarity index 100% rename from internal/library/outreach/generate_test.go rename to backend/internal/library/outreach/generate_test.go diff --git a/internal/library/permmatch/match.go b/backend/internal/library/permmatch/match.go similarity index 100% rename from internal/library/permmatch/match.go rename to backend/internal/library/permmatch/match.go diff --git a/internal/library/permmatch/match_test.go b/backend/internal/library/permmatch/match_test.go similarity index 100% rename from internal/library/permmatch/match_test.go rename to backend/internal/library/permmatch/match_test.go diff --git a/internal/library/placement/ai_generate.go b/backend/internal/library/placement/ai_generate.go similarity index 100% rename from internal/library/placement/ai_generate.go rename to backend/internal/library/placement/ai_generate.go diff --git a/internal/library/placement/context.go b/backend/internal/library/placement/context.go similarity index 100% rename from internal/library/placement/context.go rename to backend/internal/library/placement/context.go diff --git a/internal/library/placement/context_prompt.go b/backend/internal/library/placement/context_prompt.go similarity index 100% rename from internal/library/placement/context_prompt.go rename to backend/internal/library/placement/context_prompt.go diff --git a/internal/library/placement/context_prompt_test.go b/backend/internal/library/placement/context_prompt_test.go similarity index 100% rename from internal/library/placement/context_prompt_test.go rename to backend/internal/library/placement/context_prompt_test.go diff --git a/internal/library/placement/crawler_exec.go b/backend/internal/library/placement/crawler_exec.go similarity index 100% rename from internal/library/placement/crawler_exec.go rename to backend/internal/library/placement/crawler_exec.go diff --git a/internal/library/placement/crawler_exec_test.go b/backend/internal/library/placement/crawler_exec_test.go similarity index 100% rename from internal/library/placement/crawler_exec_test.go rename to backend/internal/library/placement/crawler_exec_test.go diff --git a/internal/library/placement/crawler_polite.go b/backend/internal/library/placement/crawler_polite.go similarity index 100% rename from internal/library/placement/crawler_polite.go rename to backend/internal/library/placement/crawler_polite.go diff --git a/internal/library/placement/discover.go b/backend/internal/library/placement/discover.go similarity index 100% rename from internal/library/placement/discover.go rename to backend/internal/library/placement/discover.go diff --git a/internal/library/placement/discover_route.go b/backend/internal/library/placement/discover_route.go similarity index 100% rename from internal/library/placement/discover_route.go rename to backend/internal/library/placement/discover_route.go diff --git a/internal/library/placement/discover_route_test.go b/backend/internal/library/placement/discover_route_test.go similarity index 100% rename from internal/library/placement/discover_route_test.go rename to backend/internal/library/placement/discover_route_test.go diff --git a/internal/library/placement/dual_track.go b/backend/internal/library/placement/dual_track.go similarity index 81% rename from internal/library/placement/dual_track.go rename to backend/internal/library/placement/dual_track.go index 53d4715..4f37509 100644 --- a/internal/library/placement/dual_track.go +++ b/backend/internal/library/placement/dual_track.go @@ -16,27 +16,34 @@ const ( ) type ScanCandidate struct { - Permalink string - ExternalID string - Author string - Text string - SearchTag string - QueryDimension QueryDimension - GraphNodeID string - ProductFitScore int - Source DiscoverChannel - HasRelevance bool - HasRecency bool - Priority string - AuthorVerified bool - FollowerCount int - LikeCount int - ReplyCount int - EngagementScore int - PlacementScore int - SolvedByProduct bool - PostedAt string - Replies []ReplyCandidate + Permalink string + ExternalID string + Author string + AuthorID string + AuthorAvatar string + Text string + SearchTag string + QueryDimension QueryDimension + GraphNodeID string + ProductFitScore int + Source DiscoverChannel + HasRelevance bool + HasRecency bool + Priority string + AuthorVerified bool + FollowerCount int + AuthorFollowers int + LikeCount int + ReplyCount int + EngagementScore int + PlacementScore int + SolvedByProduct bool + PostedAt string + Replies []ReplyCandidate + Embedding []float32 + SemanticScore int + EngagementPredicted int + AudienceQualityScore int } type DualTrackInput struct { @@ -180,7 +187,7 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress Priority: priority, LikeCount: post.LikeCount, ReplyCount: post.ReplyCount, - PlacementScore: computePlacementScore(post.Text, tq.ProductFitScore, tq.Dimension == QueryRecency), + PlacementScore: computePlacementScore(post.Text, tq.ProductFitScore, tq.Dimension == QueryRecency, nil, nil, nil), SolvedByProduct: tq.ProductFitScore >= 55, PostedAt: strings.TrimSpace(post.PostedAt), } @@ -324,11 +331,24 @@ func finalizeScanCandidate(item *ScanCandidate) { } else { item.Priority = "relevant" } - item.PlacementScore = computePlacementScore(item.Text, item.ProductFitScore, item.HasRecency) + var engP, semP, qualP *int + if item.EngagementPredicted > 0 { + v := item.EngagementPredicted + engP = &v + } + if item.SemanticScore > 0 { + v := item.SemanticScore + semP = &v + } + if item.AudienceQualityScore > 0 { + v := item.AudienceQualityScore + qualP = &v + } + item.PlacementScore = computePlacementScore(item.Text, item.ProductFitScore, item.HasRecency, engP, semP, qualP) item.SolvedByProduct = item.ProductFitScore >= 55 } -func computePlacementScore(text string, productFit int, recent bool) int { +func computePlacementScore(text string, productFit int, recent bool, predictedEngagement *int, semanticScore *int, audienceQuality *int) int { score := 30 + productFit/4 if HasPlacementIntent(text) { score += 20 @@ -342,6 +362,44 @@ func computePlacementScore(text string, productFit int, recent bool) int { if productFit >= 60 { score += 8 } + if predictedEngagement != nil && *predictedEngagement > 60 { + score += 10 + } + if semanticScore != nil && *semanticScore > 50 { + score += 10 + } + if audienceQuality != nil && *audienceQuality > 60 { + score += 5 + } + if score > 100 { + return 100 + } + return score +} + +func computeAudienceQuality(followerCount int, authorVerified bool, likeCount int, replyCount int) int { + score := 30 + if authorVerified { + score += 20 + } + if followerCount > 10000 { + score += 15 + } else if followerCount > 1000 { + score += 10 + } else if followerCount > 100 { + score += 5 + } + totalEngagement := likeCount + replyCount*3 + if followerCount > 0 && totalEngagement > 0 { + engagementRate := (totalEngagement * 100) / followerCount + if engagementRate > 5 { + score += 15 + } else if engagementRate > 2 { + score += 10 + } else if engagementRate > 1 { + score += 5 + } + } if score > 100 { return 100 } diff --git a/internal/library/placement/effective_strategy.go b/backend/internal/library/placement/effective_strategy.go similarity index 100% rename from internal/library/placement/effective_strategy.go rename to backend/internal/library/placement/effective_strategy.go diff --git a/internal/library/placement/effective_strategy_test.go b/backend/internal/library/placement/effective_strategy_test.go similarity index 100% rename from internal/library/placement/effective_strategy_test.go rename to backend/internal/library/placement/effective_strategy_test.go diff --git a/internal/library/placement/exclusion.go b/backend/internal/library/placement/exclusion.go similarity index 100% rename from internal/library/placement/exclusion.go rename to backend/internal/library/placement/exclusion.go diff --git a/internal/library/placement/filter.go b/backend/internal/library/placement/filter.go similarity index 100% rename from internal/library/placement/filter.go rename to backend/internal/library/placement/filter.go diff --git a/internal/library/placement/filter_test.go b/backend/internal/library/placement/filter_test.go similarity index 100% rename from internal/library/placement/filter_test.go rename to backend/internal/library/placement/filter_test.go diff --git a/internal/library/placement/parse_threads.go b/backend/internal/library/placement/parse_threads.go similarity index 100% rename from internal/library/placement/parse_threads.go rename to backend/internal/library/placement/parse_threads.go diff --git a/internal/library/placement/patrol_queries.go b/backend/internal/library/placement/patrol_queries.go similarity index 100% rename from internal/library/placement/patrol_queries.go rename to backend/internal/library/placement/patrol_queries.go diff --git a/internal/library/placement/patrol_queries_test.go b/backend/internal/library/placement/patrol_queries_test.go similarity index 100% rename from internal/library/placement/patrol_queries_test.go rename to backend/internal/library/placement/patrol_queries_test.go diff --git a/internal/library/placement/product_catalog.go b/backend/internal/library/placement/product_catalog.go similarity index 100% rename from internal/library/placement/product_catalog.go rename to backend/internal/library/placement/product_catalog.go diff --git a/internal/library/placement/product_context.go b/backend/internal/library/placement/product_context.go similarity index 100% rename from internal/library/placement/product_context.go rename to backend/internal/library/placement/product_context.go diff --git a/internal/library/placement/product_match.go b/backend/internal/library/placement/product_match.go similarity index 100% rename from internal/library/placement/product_match.go rename to backend/internal/library/placement/product_match.go diff --git a/internal/library/placement/query_build.go b/backend/internal/library/placement/query_build.go similarity index 100% rename from internal/library/placement/query_build.go rename to backend/internal/library/placement/query_build.go diff --git a/internal/library/placement/recency.go b/backend/internal/library/placement/recency.go similarity index 100% rename from internal/library/placement/recency.go rename to backend/internal/library/placement/recency.go diff --git a/internal/library/placement/research_map.go b/backend/internal/library/placement/research_map.go similarity index 100% rename from internal/library/placement/research_map.go rename to backend/internal/library/placement/research_map.go diff --git a/internal/library/placement/research_map_test.go b/backend/internal/library/placement/research_map_test.go similarity index 100% rename from internal/library/placement/research_map_test.go rename to backend/internal/library/placement/research_map_test.go diff --git a/internal/library/placement/scrape_replies.go b/backend/internal/library/placement/scrape_replies.go similarity index 100% rename from internal/library/placement/scrape_replies.go rename to backend/internal/library/placement/scrape_replies.go diff --git a/internal/library/placement/scrape_replies_test.go b/backend/internal/library/placement/scrape_replies_test.go similarity index 100% rename from internal/library/placement/scrape_replies_test.go rename to backend/internal/library/placement/scrape_replies_test.go diff --git a/internal/library/placement/source_mode.go b/backend/internal/library/placement/source_mode.go similarity index 100% rename from internal/library/placement/source_mode.go rename to backend/internal/library/placement/source_mode.go diff --git a/internal/library/placement/source_mode_test.go b/backend/internal/library/placement/source_mode_test.go similarity index 100% rename from internal/library/placement/source_mode_test.go rename to backend/internal/library/placement/source_mode_test.go diff --git a/internal/library/placement/threads_api.go b/backend/internal/library/placement/threads_api.go similarity index 100% rename from internal/library/placement/threads_api.go rename to backend/internal/library/placement/threads_api.go diff --git a/internal/library/prompt/compose.go b/backend/internal/library/prompt/compose.go similarity index 100% rename from internal/library/prompt/compose.go rename to backend/internal/library/prompt/compose.go diff --git a/internal/library/prompt/compose_test.go b/backend/internal/library/prompt/compose_test.go similarity index 100% rename from internal/library/prompt/compose_test.go rename to backend/internal/library/prompt/compose_test.go diff --git a/internal/library/prompt/errors.go b/backend/internal/library/prompt/errors.go similarity index 100% rename from internal/library/prompt/errors.go rename to backend/internal/library/prompt/errors.go diff --git a/internal/library/prompt/files/ai.chat.system.md b/backend/internal/library/prompt/files/ai.chat.system.md similarity index 100% rename from internal/library/prompt/files/ai.chat.system.md rename to backend/internal/library/prompt/files/ai.chat.system.md diff --git a/internal/library/prompt/files/ai.islander.system.md b/backend/internal/library/prompt/files/ai.islander.system.md similarity index 100% rename from internal/library/prompt/files/ai.islander.system.md rename to backend/internal/library/prompt/files/ai.islander.system.md diff --git a/internal/library/prompt/files/knowledge_graph.queries.json b/backend/internal/library/prompt/files/knowledge_graph.queries.json similarity index 100% rename from internal/library/prompt/files/knowledge_graph.queries.json rename to backend/internal/library/prompt/files/knowledge_graph.queries.json diff --git a/internal/library/prompt/files/knowledge_graph.supplemental.md b/backend/internal/library/prompt/files/knowledge_graph.supplemental.md similarity index 100% rename from internal/library/prompt/files/knowledge_graph.supplemental.md rename to backend/internal/library/prompt/files/knowledge_graph.supplemental.md diff --git a/internal/library/prompt/files/knowledge_graph.system.md b/backend/internal/library/prompt/files/knowledge_graph.system.md similarity index 100% rename from internal/library/prompt/files/knowledge_graph.system.md rename to backend/internal/library/prompt/files/knowledge_graph.system.md diff --git a/internal/library/prompt/files/knowledge_graph.user.md b/backend/internal/library/prompt/files/knowledge_graph.user.md similarity index 100% rename from internal/library/prompt/files/knowledge_graph.user.md rename to backend/internal/library/prompt/files/knowledge_graph.user.md diff --git a/internal/library/prompt/files/knowledge_graph_llm.system.md b/backend/internal/library/prompt/files/knowledge_graph_llm.system.md similarity index 100% rename from internal/library/prompt/files/knowledge_graph_llm.system.md rename to backend/internal/library/prompt/files/knowledge_graph_llm.system.md diff --git a/internal/library/prompt/files/knowledge_graph_llm.user.md b/backend/internal/library/prompt/files/knowledge_graph_llm.user.md similarity index 100% rename from internal/library/prompt/files/knowledge_graph_llm.user.md rename to backend/internal/library/prompt/files/knowledge_graph_llm.user.md diff --git a/internal/library/prompt/files/matrix_copy.system.md b/backend/internal/library/prompt/files/matrix_copy.system.md similarity index 100% rename from internal/library/prompt/files/matrix_copy.system.md rename to backend/internal/library/prompt/files/matrix_copy.system.md diff --git a/internal/library/prompt/files/matrix_copy.user.md b/backend/internal/library/prompt/files/matrix_copy.user.md similarity index 100% rename from internal/library/prompt/files/matrix_copy.user.md rename to backend/internal/library/prompt/files/matrix_copy.user.md diff --git a/internal/library/prompt/files/matrix_placement.system.md b/backend/internal/library/prompt/files/matrix_placement.system.md similarity index 100% rename from internal/library/prompt/files/matrix_placement.system.md rename to backend/internal/library/prompt/files/matrix_placement.system.md diff --git a/internal/library/prompt/files/matrix_placement.user.md b/backend/internal/library/prompt/files/matrix_placement.user.md similarity index 100% rename from internal/library/prompt/files/matrix_placement.user.md rename to backend/internal/library/prompt/files/matrix_placement.user.md diff --git a/internal/library/prompt/files/outreach_placement.system.md b/backend/internal/library/prompt/files/outreach_placement.system.md similarity index 100% rename from internal/library/prompt/files/outreach_placement.system.md rename to backend/internal/library/prompt/files/outreach_placement.system.md diff --git a/internal/library/prompt/files/outreach_placement.user.md b/backend/internal/library/prompt/files/outreach_placement.user.md similarity index 100% rename from internal/library/prompt/files/outreach_placement.user.md rename to backend/internal/library/prompt/files/outreach_placement.user.md diff --git a/internal/library/prompt/files/overlay.md b/backend/internal/library/prompt/files/overlay.md similarity index 100% rename from internal/library/prompt/files/overlay.md rename to backend/internal/library/prompt/files/overlay.md diff --git a/internal/library/prompt/files/style8d.schema.md b/backend/internal/library/prompt/files/style8d.schema.md similarity index 100% rename from internal/library/prompt/files/style8d.schema.md rename to backend/internal/library/prompt/files/style8d.schema.md diff --git a/internal/library/prompt/files/style8d.system.md b/backend/internal/library/prompt/files/style8d.system.md similarity index 100% rename from internal/library/prompt/files/style8d.system.md rename to backend/internal/library/prompt/files/style8d.system.md diff --git a/internal/library/prompt/registry.go b/backend/internal/library/prompt/registry.go similarity index 100% rename from internal/library/prompt/registry.go rename to backend/internal/library/prompt/registry.go diff --git a/backend/internal/library/ratelimit/token_bucket.go b/backend/internal/library/ratelimit/token_bucket.go new file mode 100644 index 0000000..6314ea9 --- /dev/null +++ b/backend/internal/library/ratelimit/token_bucket.go @@ -0,0 +1,62 @@ +package ratelimit + +import ( + "sync" + "time" +) + +type TokenBucket struct { + mu sync.Mutex + capacity int + tokens int + refillRate float64 // tokens per second + lastRefill time.Time +} + +func NewTokenBucket(capacity int, refillPerSecond float64) *TokenBucket { + return &TokenBucket{ + capacity: capacity, + tokens: capacity, + refillRate: refillPerSecond, + lastRefill: time.Now(), + } +} + +func (tb *TokenBucket) Allow() bool { + return tb.AllowN(1) +} + +func (tb *TokenBucket) AllowN(n int) bool { + tb.mu.Lock() + defer tb.mu.Unlock() + tb.refill() + if tb.tokens >= n { + tb.tokens -= n + return true + } + return false +} + +func (tb *TokenBucket) Remaining() int { + tb.mu.Lock() + defer tb.mu.Unlock() + tb.refill() + return tb.tokens +} + +func (tb *TokenBucket) Capacity() int { + return tb.capacity +} + +func (tb *TokenBucket) refill() { + now := time.Now() + elapsed := now.Sub(tb.lastRefill).Seconds() + tb.lastRefill = now + add := int(elapsed * tb.refillRate) + if add > 0 { + tb.tokens += add + if tb.tokens > tb.capacity { + tb.tokens = tb.capacity + } + } +} diff --git a/internal/library/redact/redact.go b/backend/internal/library/redact/redact.go similarity index 100% rename from internal/library/redact/redact.go rename to backend/internal/library/redact/redact.go diff --git a/internal/library/redact/redact_test.go b/backend/internal/library/redact/redact_test.go similarity index 100% rename from internal/library/redact/redact_test.go rename to backend/internal/library/redact/redact_test.go diff --git a/internal/library/redis/client.go b/backend/internal/library/redis/client.go similarity index 77% rename from internal/library/redis/client.go rename to backend/internal/library/redis/client.go index ff13bcd..6008893 100644 --- a/internal/library/redis/client.go +++ b/backend/internal/library/redis/client.go @@ -11,7 +11,8 @@ func NewClient(conf config.RedisConf) *goredis.Client { return nil } return goredis.NewClient(&goredis.Options{ - Addr: conf.Addr, - DB: conf.DB, + Addr: conf.Addr, + Password: conf.Password, + DB: conf.DB, }) } diff --git a/internal/library/style8d/analyze.go b/backend/internal/library/style8d/analyze.go similarity index 100% rename from internal/library/style8d/analyze.go rename to backend/internal/library/style8d/analyze.go diff --git a/internal/library/style8d/analyze_test.go b/backend/internal/library/style8d/analyze_test.go similarity index 100% rename from internal/library/style8d/analyze_test.go rename to backend/internal/library/style8d/analyze_test.go diff --git a/internal/library/style8d/prompt.go b/backend/internal/library/style8d/prompt.go similarity index 100% rename from internal/library/style8d/prompt.go rename to backend/internal/library/style8d/prompt.go diff --git a/internal/library/style8d/prompt_test.go b/backend/internal/library/style8d/prompt_test.go similarity index 100% rename from internal/library/style8d/prompt_test.go rename to backend/internal/library/style8d/prompt_test.go diff --git a/internal/library/threadsapi/client.go b/backend/internal/library/threadsapi/client.go similarity index 100% rename from internal/library/threadsapi/client.go rename to backend/internal/library/threadsapi/client.go diff --git a/backend/internal/library/threadsapi/media.go b/backend/internal/library/threadsapi/media.go new file mode 100644 index 0000000..eb62234 --- /dev/null +++ b/backend/internal/library/threadsapi/media.go @@ -0,0 +1,97 @@ +package threadsapi + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +type MediaStats struct { + LikeCount int `json:"like_count"` + ReplyCount int `json:"reply_count"` + RepostCount int `json:"repost_count"` + QuoteCount int `json:"quote_count"` +} + +func GetMediaStats(ctx context.Context, accessToken, mediaID string) (*MediaStats, error) { + u := fmt.Sprintf("%s/%s", graphBaseURL, mediaID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + q := url.Values{} + q.Set("fields", "like_count,reply_count,repost_count,quote_count") + q.Set("access_token", accessToken) + req.URL.RawQuery = q.Encode() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("threads api media stats error: %s", resp.Status) + } + + var raw struct { + ID string `json:"id"` + LikeCount int `json:"like_count"` + ReplyCount int `json:"reply_count"` + RepostCount int `json:"repost_count"` + QuoteCount int `json:"quote_count"` + } + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, err + } + return &MediaStats{ + LikeCount: raw.LikeCount, + ReplyCount: raw.ReplyCount, + RepostCount: raw.RepostCount, + QuoteCount: raw.QuoteCount, + }, nil +} + +type TokenRefreshResult struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +func RefreshAccessToken(ctx context.Context, currentToken string) (*TokenRefreshResult, error) { + u := fmt.Sprintf("%s/refresh_access_token", graphBaseURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + q := url.Values{} + q.Set("grant_type", "th_refresh_token") + q.Set("access_token", currentToken) + req.URL.RawQuery = q.Encode() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("threads api token refresh error: %s", resp.Status) + } + + var result TokenRefreshResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + if result.AccessToken == "" { + return nil, fmt.Errorf("threads api token refresh returned empty token") + } + return &result, nil +} + +func NsFromNow(seconds int) int64 { + return time.Now().UnixNano() + int64(seconds)*1e9 +} diff --git a/internal/library/threadsapi/publish.go b/backend/internal/library/threadsapi/publish.go similarity index 100% rename from internal/library/threadsapi/publish.go rename to backend/internal/library/threadsapi/publish.go diff --git a/internal/library/threadsapi/replies.go b/backend/internal/library/threadsapi/replies.go similarity index 100% rename from internal/library/threadsapi/replies.go rename to backend/internal/library/threadsapi/replies.go diff --git a/internal/library/threadspost/limits.go b/backend/internal/library/threadspost/limits.go similarity index 100% rename from internal/library/threadspost/limits.go rename to backend/internal/library/threadspost/limits.go diff --git a/internal/library/threadspost/limits_test.go b/backend/internal/library/threadspost/limits_test.go similarity index 100% rename from internal/library/threadspost/limits_test.go rename to backend/internal/library/threadspost/limits_test.go diff --git a/internal/library/validate/validate.go b/backend/internal/library/validate/validate.go similarity index 100% rename from internal/library/validate/validate.go rename to backend/internal/library/validate/validate.go diff --git a/internal/library/viral/analyze_viral.go b/backend/internal/library/viral/analyze_viral.go similarity index 100% rename from internal/library/viral/analyze_viral.go rename to backend/internal/library/viral/analyze_viral.go diff --git a/internal/library/viral/discover.go b/backend/internal/library/viral/discover.go similarity index 100% rename from internal/library/viral/discover.go rename to backend/internal/library/viral/discover.go diff --git a/internal/library/viral/discover_accounts.go b/backend/internal/library/viral/discover_accounts.go similarity index 100% rename from internal/library/viral/discover_accounts.go rename to backend/internal/library/viral/discover_accounts.go diff --git a/internal/library/viral/discover_accounts_test.go b/backend/internal/library/viral/discover_accounts_test.go similarity index 100% rename from internal/library/viral/discover_accounts_test.go rename to backend/internal/library/viral/discover_accounts_test.go diff --git a/internal/library/viral/discover_budget_test.go b/backend/internal/library/viral/discover_budget_test.go similarity index 100% rename from internal/library/viral/discover_budget_test.go rename to backend/internal/library/viral/discover_budget_test.go diff --git a/internal/library/viral/discover_graceful_test.go b/backend/internal/library/viral/discover_graceful_test.go similarity index 100% rename from internal/library/viral/discover_graceful_test.go rename to backend/internal/library/viral/discover_graceful_test.go diff --git a/internal/library/viral/enrich_accounts.go b/backend/internal/library/viral/enrich_accounts.go similarity index 100% rename from internal/library/viral/enrich_accounts.go rename to backend/internal/library/viral/enrich_accounts.go diff --git a/internal/library/viral/flexible_json.go b/backend/internal/library/viral/flexible_json.go similarity index 100% rename from internal/library/viral/flexible_json.go rename to backend/internal/library/viral/flexible_json.go diff --git a/internal/library/viral/mission_inspire.go b/backend/internal/library/viral/mission_inspire.go similarity index 100% rename from internal/library/viral/mission_inspire.go rename to backend/internal/library/viral/mission_inspire.go diff --git a/internal/library/viral/mission_inspire_collect.go b/backend/internal/library/viral/mission_inspire_collect.go similarity index 100% rename from internal/library/viral/mission_inspire_collect.go rename to backend/internal/library/viral/mission_inspire_collect.go diff --git a/internal/library/viral/mission_inspire_test.go b/backend/internal/library/viral/mission_inspire_test.go similarity index 100% rename from internal/library/viral/mission_inspire_test.go rename to backend/internal/library/viral/mission_inspire_test.go diff --git a/internal/library/viral/mission_research_map.go b/backend/internal/library/viral/mission_research_map.go similarity index 100% rename from internal/library/viral/mission_research_map.go rename to backend/internal/library/viral/mission_research_map.go diff --git a/internal/library/viral/mission_research_map_test.go b/backend/internal/library/viral/mission_research_map_test.go similarity index 100% rename from internal/library/viral/mission_research_map_test.go rename to backend/internal/library/viral/mission_research_map_test.go diff --git a/internal/library/viral/quality.go b/backend/internal/library/viral/quality.go similarity index 100% rename from internal/library/viral/quality.go rename to backend/internal/library/viral/quality.go diff --git a/internal/library/viral/quality_test.go b/backend/internal/library/viral/quality_test.go similarity index 100% rename from internal/library/viral/quality_test.go rename to backend/internal/library/viral/quality_test.go diff --git a/internal/library/viral/reference_accounts.go b/backend/internal/library/viral/reference_accounts.go similarity index 100% rename from internal/library/viral/reference_accounts.go rename to backend/internal/library/viral/reference_accounts.go diff --git a/internal/library/viral/reference_accounts_fallback_test.go b/backend/internal/library/viral/reference_accounts_fallback_test.go similarity index 100% rename from internal/library/viral/reference_accounts_fallback_test.go rename to backend/internal/library/viral/reference_accounts_fallback_test.go diff --git a/internal/library/viral/reference_accounts_test.go b/backend/internal/library/viral/reference_accounts_test.go similarity index 100% rename from internal/library/viral/reference_accounts_test.go rename to backend/internal/library/viral/reference_accounts_test.go diff --git a/internal/library/viral/replicate.go b/backend/internal/library/viral/replicate.go similarity index 100% rename from internal/library/viral/replicate.go rename to backend/internal/library/viral/replicate.go diff --git a/internal/library/viral/research_map.go b/backend/internal/library/viral/research_map.go similarity index 100% rename from internal/library/viral/research_map.go rename to backend/internal/library/viral/research_map.go diff --git a/internal/library/viral/score.go b/backend/internal/library/viral/score.go similarity index 100% rename from internal/library/viral/score.go rename to backend/internal/library/viral/score.go diff --git a/internal/library/viral/search_tags.go b/backend/internal/library/viral/search_tags.go similarity index 100% rename from internal/library/viral/search_tags.go rename to backend/internal/library/viral/search_tags.go diff --git a/internal/library/viral/search_tags_test.go b/backend/internal/library/viral/search_tags_test.go similarity index 100% rename from internal/library/viral/search_tags_test.go rename to backend/internal/library/viral/search_tags_test.go diff --git a/internal/library/viral/tags.go b/backend/internal/library/viral/tags.go similarity index 100% rename from internal/library/viral/tags.go rename to backend/internal/library/viral/tags.go diff --git a/internal/library/viral/tags_test.go b/backend/internal/library/viral/tags_test.go similarity index 100% rename from internal/library/viral/tags_test.go rename to backend/internal/library/viral/tags_test.go diff --git a/internal/library/websearch/client.go b/backend/internal/library/websearch/client.go similarity index 100% rename from internal/library/websearch/client.go rename to backend/internal/library/websearch/client.go diff --git a/internal/logic/ai/actor.go b/backend/internal/logic/ai/actor.go similarity index 100% rename from internal/logic/ai/actor.go rename to backend/internal/logic/ai/actor.go diff --git a/internal/logic/ai/chat_logic.go b/backend/internal/logic/ai/chat_logic.go similarity index 100% rename from internal/logic/ai/chat_logic.go rename to backend/internal/logic/ai/chat_logic.go diff --git a/internal/logic/ai/chat_stream_logic.go b/backend/internal/logic/ai/chat_stream_logic.go similarity index 100% rename from internal/logic/ai/chat_stream_logic.go rename to backend/internal/logic/ai/chat_stream_logic.go diff --git a/internal/logic/ai/credential.go b/backend/internal/logic/ai/credential.go similarity index 100% rename from internal/logic/ai/credential.go rename to backend/internal/logic/ai/credential.go diff --git a/internal/logic/ai/islander_chat_stream_logic.go b/backend/internal/logic/ai/islander_chat_stream_logic.go similarity index 100% rename from internal/logic/ai/islander_chat_stream_logic.go rename to backend/internal/logic/ai/islander_chat_stream_logic.go diff --git a/internal/logic/ai/list_ai_provider_models_logic.go b/backend/internal/logic/ai/list_ai_provider_models_logic.go similarity index 100% rename from internal/logic/ai/list_ai_provider_models_logic.go rename to backend/internal/logic/ai/list_ai_provider_models_logic.go diff --git a/internal/logic/ai/list_ai_providers_logic.go b/backend/internal/logic/ai/list_ai_providers_logic.go similarity index 100% rename from internal/logic/ai/list_ai_providers_logic.go rename to backend/internal/logic/ai/list_ai_providers_logic.go diff --git a/internal/logic/ai/mapper.go b/backend/internal/logic/ai/mapper.go similarity index 100% rename from internal/logic/ai/mapper.go rename to backend/internal/logic/ai/mapper.go diff --git a/internal/logic/auth/login_logic.go b/backend/internal/logic/auth/login_logic.go similarity index 100% rename from internal/logic/auth/login_logic.go rename to backend/internal/logic/auth/login_logic.go diff --git a/internal/logic/auth/logout_logic.go b/backend/internal/logic/auth/logout_logic.go similarity index 100% rename from internal/logic/auth/logout_logic.go rename to backend/internal/logic/auth/logout_logic.go diff --git a/internal/logic/auth/mapper.go b/backend/internal/logic/auth/mapper.go similarity index 100% rename from internal/logic/auth/mapper.go rename to backend/internal/logic/auth/mapper.go diff --git a/internal/logic/auth/refresh_logic.go b/backend/internal/logic/auth/refresh_logic.go similarity index 100% rename from internal/logic/auth/refresh_logic.go rename to backend/internal/logic/auth/refresh_logic.go diff --git a/internal/logic/auth/register_logic.go b/backend/internal/logic/auth/register_logic.go similarity index 100% rename from internal/logic/auth/register_logic.go rename to backend/internal/logic/auth/register_logic.go diff --git a/internal/logic/authz/admin.go b/backend/internal/logic/authz/admin.go similarity index 100% rename from internal/logic/authz/admin.go rename to backend/internal/logic/authz/admin.go diff --git a/internal/logic/brand/actor.go b/backend/internal/logic/brand/actor.go similarity index 100% rename from internal/logic/brand/actor.go rename to backend/internal/logic/brand/actor.go diff --git a/internal/logic/brand/create_brand_logic.go b/backend/internal/logic/brand/create_brand_logic.go similarity index 100% rename from internal/logic/brand/create_brand_logic.go rename to backend/internal/logic/brand/create_brand_logic.go diff --git a/internal/logic/brand/create_brand_product_logic.go b/backend/internal/logic/brand/create_brand_product_logic.go similarity index 100% rename from internal/logic/brand/create_brand_product_logic.go rename to backend/internal/logic/brand/create_brand_product_logic.go diff --git a/internal/logic/brand/delete_brand_logic.go b/backend/internal/logic/brand/delete_brand_logic.go similarity index 100% rename from internal/logic/brand/delete_brand_logic.go rename to backend/internal/logic/brand/delete_brand_logic.go diff --git a/internal/logic/brand/delete_brand_product_logic.go b/backend/internal/logic/brand/delete_brand_product_logic.go similarity index 100% rename from internal/logic/brand/delete_brand_product_logic.go rename to backend/internal/logic/brand/delete_brand_product_logic.go diff --git a/internal/logic/brand/expand_knowledge_graph_logic.go b/backend/internal/logic/brand/expand_knowledge_graph_logic.go similarity index 100% rename from internal/logic/brand/expand_knowledge_graph_logic.go rename to backend/internal/logic/brand/expand_knowledge_graph_logic.go diff --git a/internal/logic/brand/generate_brand_content_matrix_logic.go b/backend/internal/logic/brand/generate_brand_content_matrix_logic.go similarity index 100% rename from internal/logic/brand/generate_brand_content_matrix_logic.go rename to backend/internal/logic/brand/generate_brand_content_matrix_logic.go diff --git a/internal/logic/brand/generate_outreach_drafts_logic.go b/backend/internal/logic/brand/generate_outreach_drafts_logic.go similarity index 100% rename from internal/logic/brand/generate_outreach_drafts_logic.go rename to backend/internal/logic/brand/generate_outreach_drafts_logic.go diff --git a/internal/logic/brand/get_brand_content_matrix_logic.go b/backend/internal/logic/brand/get_brand_content_matrix_logic.go similarity index 100% rename from internal/logic/brand/get_brand_content_matrix_logic.go rename to backend/internal/logic/brand/get_brand_content_matrix_logic.go diff --git a/internal/logic/brand/get_brand_logic.go b/backend/internal/logic/brand/get_brand_logic.go similarity index 100% rename from internal/logic/brand/get_brand_logic.go rename to backend/internal/logic/brand/get_brand_logic.go diff --git a/internal/logic/brand/get_brand_scan_schedule_logic.go b/backend/internal/logic/brand/get_brand_scan_schedule_logic.go similarity index 100% rename from internal/logic/brand/get_brand_scan_schedule_logic.go rename to backend/internal/logic/brand/get_brand_scan_schedule_logic.go diff --git a/internal/logic/brand/get_knowledge_graph_logic.go b/backend/internal/logic/brand/get_knowledge_graph_logic.go similarity index 100% rename from internal/logic/brand/get_knowledge_graph_logic.go rename to backend/internal/logic/brand/get_knowledge_graph_logic.go diff --git a/internal/logic/brand/list_brand_products_logic.go b/backend/internal/logic/brand/list_brand_products_logic.go similarity index 100% rename from internal/logic/brand/list_brand_products_logic.go rename to backend/internal/logic/brand/list_brand_products_logic.go diff --git a/internal/logic/brand/list_brand_scan_posts_logic.go b/backend/internal/logic/brand/list_brand_scan_posts_logic.go similarity index 100% rename from internal/logic/brand/list_brand_scan_posts_logic.go rename to backend/internal/logic/brand/list_brand_scan_posts_logic.go diff --git a/internal/logic/brand/list_brands_logic.go b/backend/internal/logic/brand/list_brands_logic.go similarity index 100% rename from internal/logic/brand/list_brands_logic.go rename to backend/internal/logic/brand/list_brands_logic.go diff --git a/internal/logic/brand/mapper.go b/backend/internal/logic/brand/mapper.go similarity index 100% rename from internal/logic/brand/mapper.go rename to backend/internal/logic/brand/mapper.go diff --git a/internal/logic/brand/patch_knowledge_graph_nodes_logic.go b/backend/internal/logic/brand/patch_knowledge_graph_nodes_logic.go similarity index 100% rename from internal/logic/brand/patch_knowledge_graph_nodes_logic.go rename to backend/internal/logic/brand/patch_knowledge_graph_nodes_logic.go diff --git a/internal/logic/brand/patch_scan_post_outreach_logic.go b/backend/internal/logic/brand/patch_scan_post_outreach_logic.go similarity index 100% rename from internal/logic/brand/patch_scan_post_outreach_logic.go rename to backend/internal/logic/brand/patch_scan_post_outreach_logic.go diff --git a/internal/logic/brand/publish_outreach_draft_logic.go b/backend/internal/logic/brand/publish_outreach_draft_logic.go similarity index 100% rename from internal/logic/brand/publish_outreach_draft_logic.go rename to backend/internal/logic/brand/publish_outreach_draft_logic.go diff --git a/internal/logic/brand/start_brand_scan_job_logic.go b/backend/internal/logic/brand/start_brand_scan_job_logic.go similarity index 100% rename from internal/logic/brand/start_brand_scan_job_logic.go rename to backend/internal/logic/brand/start_brand_scan_job_logic.go diff --git a/internal/logic/brand/update_brand_logic.go b/backend/internal/logic/brand/update_brand_logic.go similarity index 100% rename from internal/logic/brand/update_brand_logic.go rename to backend/internal/logic/brand/update_brand_logic.go diff --git a/internal/logic/brand/update_brand_product_logic.go b/backend/internal/logic/brand/update_brand_product_logic.go similarity index 100% rename from internal/logic/brand/update_brand_product_logic.go rename to backend/internal/logic/brand/update_brand_product_logic.go diff --git a/internal/logic/brand/upsert_brand_scan_schedule_logic.go b/backend/internal/logic/brand/upsert_brand_scan_schedule_logic.go similarity index 100% rename from internal/logic/brand/upsert_brand_scan_schedule_logic.go rename to backend/internal/logic/brand/upsert_brand_scan_schedule_logic.go diff --git a/internal/logic/copy_mission/actor.go b/backend/internal/logic/copy_mission/actor.go similarity index 100% rename from internal/logic/copy_mission/actor.go rename to backend/internal/logic/copy_mission/actor.go diff --git a/internal/logic/copy_mission/create_copy_mission_logic.go b/backend/internal/logic/copy_mission/create_copy_mission_logic.go similarity index 100% rename from internal/logic/copy_mission/create_copy_mission_logic.go rename to backend/internal/logic/copy_mission/create_copy_mission_logic.go diff --git a/internal/logic/copy_mission/delete_copy_mission_logic.go b/backend/internal/logic/copy_mission/delete_copy_mission_logic.go similarity index 100% rename from internal/logic/copy_mission/delete_copy_mission_logic.go rename to backend/internal/logic/copy_mission/delete_copy_mission_logic.go diff --git a/internal/logic/copy_mission/generate_copy_mission_matrix_logic.go b/backend/internal/logic/copy_mission/generate_copy_mission_matrix_logic.go similarity index 100% rename from internal/logic/copy_mission/generate_copy_mission_matrix_logic.go rename to backend/internal/logic/copy_mission/generate_copy_mission_matrix_logic.go diff --git a/internal/logic/copy_mission/get_copy_mission_logic.go b/backend/internal/logic/copy_mission/get_copy_mission_logic.go similarity index 100% rename from internal/logic/copy_mission/get_copy_mission_logic.go rename to backend/internal/logic/copy_mission/get_copy_mission_logic.go diff --git a/internal/logic/copy_mission/get_copy_mission_scan_schedule_logic.go b/backend/internal/logic/copy_mission/get_copy_mission_scan_schedule_logic.go similarity index 100% rename from internal/logic/copy_mission/get_copy_mission_scan_schedule_logic.go rename to backend/internal/logic/copy_mission/get_copy_mission_scan_schedule_logic.go diff --git a/internal/logic/copy_mission/inspire_copy_mission_logic.go b/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go similarity index 100% rename from internal/logic/copy_mission/inspire_copy_mission_logic.go rename to backend/internal/logic/copy_mission/inspire_copy_mission_logic.go diff --git a/internal/logic/copy_mission/list_copy_mission_copy_drafts_logic.go b/backend/internal/logic/copy_mission/list_copy_mission_copy_drafts_logic.go similarity index 100% rename from internal/logic/copy_mission/list_copy_mission_copy_drafts_logic.go rename to backend/internal/logic/copy_mission/list_copy_mission_copy_drafts_logic.go diff --git a/internal/logic/copy_mission/list_copy_mission_scan_posts_logic.go b/backend/internal/logic/copy_mission/list_copy_mission_scan_posts_logic.go similarity index 100% rename from internal/logic/copy_mission/list_copy_mission_scan_posts_logic.go rename to backend/internal/logic/copy_mission/list_copy_mission_scan_posts_logic.go diff --git a/internal/logic/copy_mission/list_copy_missions_logic.go b/backend/internal/logic/copy_mission/list_copy_missions_logic.go similarity index 100% rename from internal/logic/copy_mission/list_copy_missions_logic.go rename to backend/internal/logic/copy_mission/list_copy_missions_logic.go diff --git a/internal/logic/copy_mission/mapper.go b/backend/internal/logic/copy_mission/mapper.go similarity index 100% rename from internal/logic/copy_mission/mapper.go rename to backend/internal/logic/copy_mission/mapper.go diff --git a/internal/logic/copy_mission/schedule_helper.go b/backend/internal/logic/copy_mission/schedule_helper.go similarity index 100% rename from internal/logic/copy_mission/schedule_helper.go rename to backend/internal/logic/copy_mission/schedule_helper.go diff --git a/internal/logic/copy_mission/start_copy_mission_analyze_job_logic.go b/backend/internal/logic/copy_mission/start_copy_mission_analyze_job_logic.go similarity index 100% rename from internal/logic/copy_mission/start_copy_mission_analyze_job_logic.go rename to backend/internal/logic/copy_mission/start_copy_mission_analyze_job_logic.go diff --git a/internal/logic/copy_mission/start_copy_mission_copy_draft_job_logic.go b/backend/internal/logic/copy_mission/start_copy_mission_copy_draft_job_logic.go similarity index 100% rename from internal/logic/copy_mission/start_copy_mission_copy_draft_job_logic.go rename to backend/internal/logic/copy_mission/start_copy_mission_copy_draft_job_logic.go diff --git a/internal/logic/copy_mission/start_copy_mission_matrix_job_logic.go b/backend/internal/logic/copy_mission/start_copy_mission_matrix_job_logic.go similarity index 100% rename from internal/logic/copy_mission/start_copy_mission_matrix_job_logic.go rename to backend/internal/logic/copy_mission/start_copy_mission_matrix_job_logic.go diff --git a/internal/logic/copy_mission/start_copy_mission_scan_job_logic.go b/backend/internal/logic/copy_mission/start_copy_mission_scan_job_logic.go similarity index 100% rename from internal/logic/copy_mission/start_copy_mission_scan_job_logic.go rename to backend/internal/logic/copy_mission/start_copy_mission_scan_job_logic.go diff --git a/internal/logic/copy_mission/update_copy_mission_logic.go b/backend/internal/logic/copy_mission/update_copy_mission_logic.go similarity index 100% rename from internal/logic/copy_mission/update_copy_mission_logic.go rename to backend/internal/logic/copy_mission/update_copy_mission_logic.go diff --git a/internal/logic/copy_mission/upsert_copy_mission_scan_schedule_logic.go b/backend/internal/logic/copy_mission/upsert_copy_mission_scan_schedule_logic.go similarity index 100% rename from internal/logic/copy_mission/upsert_copy_mission_scan_schedule_logic.go rename to backend/internal/logic/copy_mission/upsert_copy_mission_scan_schedule_logic.go diff --git a/backend/internal/logic/crm/actor.go b/backend/internal/logic/crm/actor.go new file mode 100644 index 0000000..ff363fc --- /dev/null +++ b/backend/internal/logic/crm/actor.go @@ -0,0 +1,17 @@ +package crm + +import ( + "context" + + "haixun-backend/internal/library/authctx" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" +) + +func actorFrom(ctx context.Context) (tenantID, uid string, err error) { + actor, ok := authctx.ActorFromContext(ctx) + if !ok { + return "", "", app.For(code.Auth).AuthUnauthorized("missing actor") + } + return actor.TenantID, actor.UID, nil +} diff --git a/backend/internal/logic/crm/create_crm_contact_logic.go b/backend/internal/logic/crm/create_crm_contact_logic.go new file mode 100644 index 0000000..6cc276b --- /dev/null +++ b/backend/internal/logic/crm/create_crm_contact_logic.go @@ -0,0 +1,46 @@ +package crm + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type CreateCrmContactLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateCrmContactLogic { + return &CreateCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *CreateCrmContactLogic) CreateCrmContact(req *types.CreateCrmContactReq) (*types.CrmContactData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + + authorID := strings.TrimSpace(req.AuthorID) + authorName := strings.TrimSpace(req.AuthorName) + if authorID == "" { + return nil, app.For(code.Persona).InputMissingRequired("author_id is required") + } + if authorName == "" { + return nil, app.For(code.Persona).InputMissingRequired("author_name is required") + } + + result, err := l.svcCtx.CrmContact.Create(l.ctx, tenantID, uid, strings.TrimSpace(req.BrandID), authorID, authorName) + if err != nil { + return nil, err + } + + return toCrmContactData(result), nil +} diff --git a/backend/internal/logic/crm/delete_crm_contact_logic.go b/backend/internal/logic/crm/delete_crm_contact_logic.go new file mode 100644 index 0000000..669ebcf --- /dev/null +++ b/backend/internal/logic/crm/delete_crm_contact_logic.go @@ -0,0 +1,29 @@ +package crm + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type DeleteCrmContactLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeleteCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteCrmContactLogic { + return &DeleteCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *DeleteCrmContactLogic) DeleteCrmContact(req *types.CrmContactIDPath) error { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return err + } + + return l.svcCtx.CrmContact.Delete(l.ctx, tenantID, uid, req.ID) +} diff --git a/backend/internal/logic/crm/get_crm_contact_logic.go b/backend/internal/logic/crm/get_crm_contact_logic.go new file mode 100644 index 0000000..943f6c6 --- /dev/null +++ b/backend/internal/logic/crm/get_crm_contact_logic.go @@ -0,0 +1,34 @@ +package crm + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetCrmContactLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCrmContactLogic { + return &GetCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetCrmContactLogic) GetCrmContact(req *types.CrmContactIDPath) (*types.CrmContactData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + + result, err := l.svcCtx.CrmContact.Get(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + + return toCrmContactData(result), nil +} diff --git a/backend/internal/logic/crm/list_crm_contacts_logic.go b/backend/internal/logic/crm/list_crm_contacts_logic.go new file mode 100644 index 0000000..ce16203 --- /dev/null +++ b/backend/internal/logic/crm/list_crm_contacts_logic.go @@ -0,0 +1,54 @@ +package crm + +import ( + "context" + + crmusecase "haixun-backend/internal/model/crm_contact/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListCrmContactsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListCrmContactsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCrmContactsLogic { + return &ListCrmContactsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListCrmContactsLogic) ListCrmContacts(req *types.ListCrmContactsReq) (*types.CrmContactListData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + + page := req.Page + pageSize := req.PageSize + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + + listReq := crmusecase.ListRequest{ + TenantID: tenantID, + OwnerUID: uid, + BrandID: req.BrandID, + Status: req.Status, + Tag: req.Tag, + Page: page, + PageSize: pageSize, + } + + items, total, err := l.svcCtx.CrmContact.List(l.ctx, listReq) + if err != nil { + return nil, err + } + + return toCrmContactListData(items, total, page, pageSize), nil +} diff --git a/backend/internal/logic/crm/mapper.go b/backend/internal/logic/crm/mapper.go new file mode 100644 index 0000000..48a9738 --- /dev/null +++ b/backend/internal/logic/crm/mapper.go @@ -0,0 +1,48 @@ +package crm + +import ( + crmusecase "haixun-backend/internal/model/crm_contact/domain/usecase" + "haixun-backend/internal/types" +) + +func toCrmContactData(item *crmusecase.CRMContactSummary) *types.CrmContactData { + if item == nil { + return nil + } + return &types.CrmContactData{ + ID: item.ID, + BrandID: item.BrandID, + AuthorID: item.AuthorID, + AuthorName: item.AuthorName, + AuthorAvatar: item.AuthorAvatar, + AuthorFollowers: item.AuthorFollowers, + ScanPostID: item.ScanPostID, + Notes: item.Notes, + Tags: item.Tags, + Status: item.Status, + OutreachCount: item.OutreachCount, + LastContactedAt: item.LastContactedAt, + CreateAt: item.CreateAt, + UpdateAt: item.UpdateAt, + } +} + +func toCrmContactListData(items []crmusecase.CRMContactSummary, total, page, pageSize int) *types.CrmContactListData { + totalPages := 0 + if total > 0 { + totalPages = (total + pageSize - 1) / pageSize + } + data := make([]types.CrmContactData, 0, len(items)) + for _, item := range items { + data = append(data, *toCrmContactData(&item)) + } + return &types.CrmContactListData{ + List: data, + Pagination: types.PaginationData{ + Total: int64(total), + Page: int64(page), + PageSize: int64(pageSize), + TotalPages: int64(totalPages), + }, + } +} diff --git a/backend/internal/logic/crm/update_crm_contact_logic.go b/backend/internal/logic/crm/update_crm_contact_logic.go new file mode 100644 index 0000000..853c5e4 --- /dev/null +++ b/backend/internal/logic/crm/update_crm_contact_logic.go @@ -0,0 +1,42 @@ +package crm + +import ( + "context" + + crmusecase "haixun-backend/internal/model/crm_contact/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpdateCrmContactLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateCrmContactLogic { + return &UpdateCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *UpdateCrmContactLogic) UpdateCrmContact(req *types.UpdateCrmContactReq) (*types.CrmContactData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + + result, err := l.svcCtx.CrmContact.Update(l.ctx, crmusecase.UpdateRequest{ + TenantID: tenantID, + OwnerUID: uid, + ContactID: req.ID, + Notes: req.Notes, + Tags: req.Tags, + Status: req.Status, + }) + if err != nil { + return nil, err + } + + return toCrmContactData(result), nil +} diff --git a/internal/logic/job/ack_worker_job_cancel_logic.go b/backend/internal/logic/job/ack_worker_job_cancel_logic.go similarity index 100% rename from internal/logic/job/ack_worker_job_cancel_logic.go rename to backend/internal/logic/job/ack_worker_job_cancel_logic.go diff --git a/internal/logic/job/actor.go b/backend/internal/logic/job/actor.go similarity index 100% rename from internal/logic/job/actor.go rename to backend/internal/logic/job/actor.go diff --git a/internal/logic/job/analyze_style8_d_from_worker_logic.go b/backend/internal/logic/job/analyze_style8_d_from_worker_logic.go similarity index 100% rename from internal/logic/job/analyze_style8_d_from_worker_logic.go rename to backend/internal/logic/job/analyze_style8_d_from_worker_logic.go diff --git a/internal/logic/job/cancel_job_logic.go b/backend/internal/logic/job/cancel_job_logic.go similarity index 100% rename from internal/logic/job/cancel_job_logic.go rename to backend/internal/logic/job/cancel_job_logic.go diff --git a/internal/logic/job/check_worker_job_cancel_logic.go b/backend/internal/logic/job/check_worker_job_cancel_logic.go similarity index 100% rename from internal/logic/job/check_worker_job_cancel_logic.go rename to backend/internal/logic/job/check_worker_job_cancel_logic.go diff --git a/internal/logic/job/claim_worker_job_logic.go b/backend/internal/logic/job/claim_worker_job_logic.go similarity index 100% rename from internal/logic/job/claim_worker_job_logic.go rename to backend/internal/logic/job/claim_worker_job_logic.go diff --git a/internal/logic/job/complete_worker_job_logic.go b/backend/internal/logic/job/complete_worker_job_logic.go similarity index 100% rename from internal/logic/job/complete_worker_job_logic.go rename to backend/internal/logic/job/complete_worker_job_logic.go diff --git a/internal/logic/job/create_job_logic.go b/backend/internal/logic/job/create_job_logic.go similarity index 100% rename from internal/logic/job/create_job_logic.go rename to backend/internal/logic/job/create_job_logic.go diff --git a/internal/logic/job/create_job_schedule_logic.go b/backend/internal/logic/job/create_job_schedule_logic.go similarity index 100% rename from internal/logic/job/create_job_schedule_logic.go rename to backend/internal/logic/job/create_job_schedule_logic.go diff --git a/internal/logic/job/delete_job_schedule_logic.go b/backend/internal/logic/job/delete_job_schedule_logic.go similarity index 100% rename from internal/logic/job/delete_job_schedule_logic.go rename to backend/internal/logic/job/delete_job_schedule_logic.go diff --git a/internal/logic/job/disable_job_schedule_logic.go b/backend/internal/logic/job/disable_job_schedule_logic.go similarity index 100% rename from internal/logic/job/disable_job_schedule_logic.go rename to backend/internal/logic/job/disable_job_schedule_logic.go diff --git a/internal/logic/job/enable_job_schedule_logic.go b/backend/internal/logic/job/enable_job_schedule_logic.go similarity index 100% rename from internal/logic/job/enable_job_schedule_logic.go rename to backend/internal/logic/job/enable_job_schedule_logic.go diff --git a/internal/logic/job/fail_worker_job_logic.go b/backend/internal/logic/job/fail_worker_job_logic.go similarity index 100% rename from internal/logic/job/fail_worker_job_logic.go rename to backend/internal/logic/job/fail_worker_job_logic.go diff --git a/internal/logic/job/get_job_logic.go b/backend/internal/logic/job/get_job_logic.go similarity index 100% rename from internal/logic/job/get_job_logic.go rename to backend/internal/logic/job/get_job_logic.go diff --git a/internal/logic/job/get_job_template_logic.go b/backend/internal/logic/job/get_job_template_logic.go similarity index 100% rename from internal/logic/job/get_job_template_logic.go rename to backend/internal/logic/job/get_job_template_logic.go diff --git a/internal/logic/job/get_worker_threads_account_session_logic.go b/backend/internal/logic/job/get_worker_threads_account_session_logic.go similarity index 100% rename from internal/logic/job/get_worker_threads_account_session_logic.go rename to backend/internal/logic/job/get_worker_threads_account_session_logic.go diff --git a/internal/logic/job/list_job_events_logic.go b/backend/internal/logic/job/list_job_events_logic.go similarity index 100% rename from internal/logic/job/list_job_events_logic.go rename to backend/internal/logic/job/list_job_events_logic.go diff --git a/internal/logic/job/list_job_schedules_logic.go b/backend/internal/logic/job/list_job_schedules_logic.go similarity index 100% rename from internal/logic/job/list_job_schedules_logic.go rename to backend/internal/logic/job/list_job_schedules_logic.go diff --git a/internal/logic/job/list_job_templates_logic.go b/backend/internal/logic/job/list_job_templates_logic.go similarity index 100% rename from internal/logic/job/list_job_templates_logic.go rename to backend/internal/logic/job/list_job_templates_logic.go diff --git a/internal/logic/job/list_jobs_logic.go b/backend/internal/logic/job/list_jobs_logic.go similarity index 100% rename from internal/logic/job/list_jobs_logic.go rename to backend/internal/logic/job/list_jobs_logic.go diff --git a/internal/logic/job/mapper.go b/backend/internal/logic/job/mapper.go similarity index 100% rename from internal/logic/job/mapper.go rename to backend/internal/logic/job/mapper.go diff --git a/internal/logic/job/ownership.go b/backend/internal/logic/job/ownership.go similarity index 100% rename from internal/logic/job/ownership.go rename to backend/internal/logic/job/ownership.go diff --git a/internal/logic/job/ownership_test.go b/backend/internal/logic/job/ownership_test.go similarity index 100% rename from internal/logic/job/ownership_test.go rename to backend/internal/logic/job/ownership_test.go diff --git a/internal/logic/job/refresh_worker_job_lock_logic.go b/backend/internal/logic/job/refresh_worker_job_lock_logic.go similarity index 100% rename from internal/logic/job/refresh_worker_job_lock_logic.go rename to backend/internal/logic/job/refresh_worker_job_lock_logic.go diff --git a/internal/logic/job/retry_job_logic.go b/backend/internal/logic/job/retry_job_logic.go similarity index 100% rename from internal/logic/job/retry_job_logic.go rename to backend/internal/logic/job/retry_job_logic.go diff --git a/internal/logic/job/store_persona_style_profile_from_worker_logic.go b/backend/internal/logic/job/store_persona_style_profile_from_worker_logic.go similarity index 100% rename from internal/logic/job/store_persona_style_profile_from_worker_logic.go rename to backend/internal/logic/job/store_persona_style_profile_from_worker_logic.go diff --git a/internal/logic/job/update_job_schedule_logic.go b/backend/internal/logic/job/update_job_schedule_logic.go similarity index 100% rename from internal/logic/job/update_job_schedule_logic.go rename to backend/internal/logic/job/update_job_schedule_logic.go diff --git a/internal/logic/job/update_worker_job_progress_logic.go b/backend/internal/logic/job/update_worker_job_progress_logic.go similarity index 100% rename from internal/logic/job/update_worker_job_progress_logic.go rename to backend/internal/logic/job/update_worker_job_progress_logic.go diff --git a/internal/logic/job/upsert_job_template_logic.go b/backend/internal/logic/job/upsert_job_template_logic.go similarity index 100% rename from internal/logic/job/upsert_job_template_logic.go rename to backend/internal/logic/job/upsert_job_template_logic.go diff --git a/internal/logic/job/worker_helpers.go b/backend/internal/logic/job/worker_helpers.go similarity index 100% rename from internal/logic/job/worker_helpers.go rename to backend/internal/logic/job/worker_helpers.go diff --git a/internal/logic/member/get_member_me_logic.go b/backend/internal/logic/member/get_member_me_logic.go similarity index 100% rename from internal/logic/member/get_member_me_logic.go rename to backend/internal/logic/member/get_member_me_logic.go diff --git a/internal/logic/member/get_member_placement_settings_logic.go b/backend/internal/logic/member/get_member_placement_settings_logic.go similarity index 100% rename from internal/logic/member/get_member_placement_settings_logic.go rename to backend/internal/logic/member/get_member_placement_settings_logic.go diff --git a/internal/logic/member/mapper.go b/backend/internal/logic/member/mapper.go similarity index 100% rename from internal/logic/member/mapper.go rename to backend/internal/logic/member/mapper.go diff --git a/internal/logic/member/update_member_me_logic.go b/backend/internal/logic/member/update_member_me_logic.go similarity index 100% rename from internal/logic/member/update_member_me_logic.go rename to backend/internal/logic/member/update_member_me_logic.go diff --git a/internal/logic/member/update_member_placement_settings_logic.go b/backend/internal/logic/member/update_member_placement_settings_logic.go similarity index 100% rename from internal/logic/member/update_member_placement_settings_logic.go rename to backend/internal/logic/member/update_member_placement_settings_logic.go diff --git a/internal/logic/normal/health_logic.go b/backend/internal/logic/normal/health_logic.go similarity index 100% rename from internal/logic/normal/health_logic.go rename to backend/internal/logic/normal/health_logic.go diff --git a/internal/logic/permission/get_me_permissions_logic.go b/backend/internal/logic/permission/get_me_permissions_logic.go similarity index 100% rename from internal/logic/permission/get_me_permissions_logic.go rename to backend/internal/logic/permission/get_me_permissions_logic.go diff --git a/internal/logic/permission/get_permission_catalog_logic.go b/backend/internal/logic/permission/get_permission_catalog_logic.go similarity index 100% rename from internal/logic/permission/get_permission_catalog_logic.go rename to backend/internal/logic/permission/get_permission_catalog_logic.go diff --git a/internal/logic/permission/mapper.go b/backend/internal/logic/permission/mapper.go similarity index 100% rename from internal/logic/permission/mapper.go rename to backend/internal/logic/permission/mapper.go diff --git a/internal/logic/persona/actor.go b/backend/internal/logic/persona/actor.go similarity index 100% rename from internal/logic/persona/actor.go rename to backend/internal/logic/persona/actor.go diff --git a/internal/logic/persona/create_persona_logic.go b/backend/internal/logic/persona/create_persona_logic.go similarity index 100% rename from internal/logic/persona/create_persona_logic.go rename to backend/internal/logic/persona/create_persona_logic.go diff --git a/internal/logic/persona/delete_persona_logic.go b/backend/internal/logic/persona/delete_persona_logic.go similarity index 100% rename from internal/logic/persona/delete_persona_logic.go rename to backend/internal/logic/persona/delete_persona_logic.go diff --git a/internal/logic/persona/generate_persona_copy_draft_logic.go b/backend/internal/logic/persona/generate_persona_copy_draft_logic.go similarity index 100% rename from internal/logic/persona/generate_persona_copy_draft_logic.go rename to backend/internal/logic/persona/generate_persona_copy_draft_logic.go diff --git a/internal/logic/persona/get_persona_logic.go b/backend/internal/logic/persona/get_persona_logic.go similarity index 100% rename from internal/logic/persona/get_persona_logic.go rename to backend/internal/logic/persona/get_persona_logic.go diff --git a/internal/logic/persona/list_persona_copy_drafts_logic.go b/backend/internal/logic/persona/list_persona_copy_drafts_logic.go similarity index 100% rename from internal/logic/persona/list_persona_copy_drafts_logic.go rename to backend/internal/logic/persona/list_persona_copy_drafts_logic.go diff --git a/internal/logic/persona/list_persona_viral_scan_posts_logic.go b/backend/internal/logic/persona/list_persona_viral_scan_posts_logic.go similarity index 100% rename from internal/logic/persona/list_persona_viral_scan_posts_logic.go rename to backend/internal/logic/persona/list_persona_viral_scan_posts_logic.go diff --git a/internal/logic/persona/list_personas_logic.go b/backend/internal/logic/persona/list_personas_logic.go similarity index 100% rename from internal/logic/persona/list_personas_logic.go rename to backend/internal/logic/persona/list_personas_logic.go diff --git a/internal/logic/persona/mapper.go b/backend/internal/logic/persona/mapper.go similarity index 100% rename from internal/logic/persona/mapper.go rename to backend/internal/logic/persona/mapper.go diff --git a/internal/logic/persona/publish_persona_copy_draft_logic.go b/backend/internal/logic/persona/publish_persona_copy_draft_logic.go similarity index 100% rename from internal/logic/persona/publish_persona_copy_draft_logic.go rename to backend/internal/logic/persona/publish_persona_copy_draft_logic.go diff --git a/internal/logic/persona/start_persona_style_analysis_logic.go b/backend/internal/logic/persona/start_persona_style_analysis_logic.go similarity index 100% rename from internal/logic/persona/start_persona_style_analysis_logic.go rename to backend/internal/logic/persona/start_persona_style_analysis_logic.go diff --git a/internal/logic/persona/start_persona_viral_scan_job_logic.go b/backend/internal/logic/persona/start_persona_viral_scan_job_logic.go similarity index 100% rename from internal/logic/persona/start_persona_viral_scan_job_logic.go rename to backend/internal/logic/persona/start_persona_viral_scan_job_logic.go diff --git a/internal/logic/persona/update_persona_copy_draft_logic.go b/backend/internal/logic/persona/update_persona_copy_draft_logic.go similarity index 100% rename from internal/logic/persona/update_persona_copy_draft_logic.go rename to backend/internal/logic/persona/update_persona_copy_draft_logic.go diff --git a/internal/logic/persona/update_persona_logic.go b/backend/internal/logic/persona/update_persona_logic.go similarity index 100% rename from internal/logic/persona/update_persona_logic.go rename to backend/internal/logic/persona/update_persona_logic.go diff --git a/internal/logic/placement_topic/actor.go b/backend/internal/logic/placement_topic/actor.go similarity index 100% rename from internal/logic/placement_topic/actor.go rename to backend/internal/logic/placement_topic/actor.go diff --git a/internal/logic/placement_topic/batch_delete_placement_topic_scan_posts_logic.go b/backend/internal/logic/placement_topic/batch_delete_placement_topic_scan_posts_logic.go similarity index 100% rename from internal/logic/placement_topic/batch_delete_placement_topic_scan_posts_logic.go rename to backend/internal/logic/placement_topic/batch_delete_placement_topic_scan_posts_logic.go diff --git a/internal/logic/placement_topic/create_placement_topic_logic.go b/backend/internal/logic/placement_topic/create_placement_topic_logic.go similarity index 100% rename from internal/logic/placement_topic/create_placement_topic_logic.go rename to backend/internal/logic/placement_topic/create_placement_topic_logic.go diff --git a/internal/logic/placement_topic/delete_placement_topic_logic.go b/backend/internal/logic/placement_topic/delete_placement_topic_logic.go similarity index 100% rename from internal/logic/placement_topic/delete_placement_topic_logic.go rename to backend/internal/logic/placement_topic/delete_placement_topic_logic.go diff --git a/internal/logic/placement_topic/delete_placement_topic_scan_post_logic.go b/backend/internal/logic/placement_topic/delete_placement_topic_scan_post_logic.go similarity index 100% rename from internal/logic/placement_topic/delete_placement_topic_scan_post_logic.go rename to backend/internal/logic/placement_topic/delete_placement_topic_scan_post_logic.go diff --git a/internal/logic/placement_topic/expand_placement_topic_graph_logic.go b/backend/internal/logic/placement_topic/expand_placement_topic_graph_logic.go similarity index 100% rename from internal/logic/placement_topic/expand_placement_topic_graph_logic.go rename to backend/internal/logic/placement_topic/expand_placement_topic_graph_logic.go diff --git a/internal/logic/placement_topic/generate_placement_topic_content_matrix_logic.go b/backend/internal/logic/placement_topic/generate_placement_topic_content_matrix_logic.go similarity index 100% rename from internal/logic/placement_topic/generate_placement_topic_content_matrix_logic.go rename to backend/internal/logic/placement_topic/generate_placement_topic_content_matrix_logic.go diff --git a/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go b/backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go similarity index 100% rename from internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go rename to backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go diff --git a/internal/logic/placement_topic/get_placement_topic_content_matrix_logic.go b/backend/internal/logic/placement_topic/get_placement_topic_content_matrix_logic.go similarity index 100% rename from internal/logic/placement_topic/get_placement_topic_content_matrix_logic.go rename to backend/internal/logic/placement_topic/get_placement_topic_content_matrix_logic.go diff --git a/internal/logic/placement_topic/get_placement_topic_graph_logic.go b/backend/internal/logic/placement_topic/get_placement_topic_graph_logic.go similarity index 100% rename from internal/logic/placement_topic/get_placement_topic_graph_logic.go rename to backend/internal/logic/placement_topic/get_placement_topic_graph_logic.go diff --git a/internal/logic/placement_topic/get_placement_topic_logic.go b/backend/internal/logic/placement_topic/get_placement_topic_logic.go similarity index 100% rename from internal/logic/placement_topic/get_placement_topic_logic.go rename to backend/internal/logic/placement_topic/get_placement_topic_logic.go diff --git a/internal/logic/placement_topic/get_placement_topic_scan_schedule_logic.go b/backend/internal/logic/placement_topic/get_placement_topic_scan_schedule_logic.go similarity index 100% rename from internal/logic/placement_topic/get_placement_topic_scan_schedule_logic.go rename to backend/internal/logic/placement_topic/get_placement_topic_scan_schedule_logic.go diff --git a/internal/logic/placement_topic/list_placement_topic_scan_posts_logic.go b/backend/internal/logic/placement_topic/list_placement_topic_scan_posts_logic.go similarity index 100% rename from internal/logic/placement_topic/list_placement_topic_scan_posts_logic.go rename to backend/internal/logic/placement_topic/list_placement_topic_scan_posts_logic.go diff --git a/internal/logic/placement_topic/list_placement_topics_logic.go b/backend/internal/logic/placement_topic/list_placement_topics_logic.go similarity index 100% rename from internal/logic/placement_topic/list_placement_topics_logic.go rename to backend/internal/logic/placement_topic/list_placement_topics_logic.go diff --git a/internal/logic/placement_topic/mapper.go b/backend/internal/logic/placement_topic/mapper.go similarity index 100% rename from internal/logic/placement_topic/mapper.go rename to backend/internal/logic/placement_topic/mapper.go diff --git a/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go b/backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go similarity index 100% rename from internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go rename to backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go diff --git a/internal/logic/placement_topic/patch_placement_topic_scan_post_outreach_logic.go b/backend/internal/logic/placement_topic/patch_placement_topic_scan_post_outreach_logic.go similarity index 100% rename from internal/logic/placement_topic/patch_placement_topic_scan_post_outreach_logic.go rename to backend/internal/logic/placement_topic/patch_placement_topic_scan_post_outreach_logic.go diff --git a/internal/logic/placement_topic/publish_placement_topic_outreach_draft_logic.go b/backend/internal/logic/placement_topic/publish_placement_topic_outreach_draft_logic.go similarity index 100% rename from internal/logic/placement_topic/publish_placement_topic_outreach_draft_logic.go rename to backend/internal/logic/placement_topic/publish_placement_topic_outreach_draft_logic.go diff --git a/internal/logic/placement_topic/scope.go b/backend/internal/logic/placement_topic/scope.go similarity index 100% rename from internal/logic/placement_topic/scope.go rename to backend/internal/logic/placement_topic/scope.go diff --git a/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go b/backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go similarity index 100% rename from internal/logic/placement_topic/start_placement_topic_scan_job_logic.go rename to backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go diff --git a/internal/logic/placement_topic/update_placement_topic_logic.go b/backend/internal/logic/placement_topic/update_placement_topic_logic.go similarity index 100% rename from internal/logic/placement_topic/update_placement_topic_logic.go rename to backend/internal/logic/placement_topic/update_placement_topic_logic.go diff --git a/internal/logic/placement_topic/upsert_placement_topic_scan_schedule_logic.go b/backend/internal/logic/placement_topic/upsert_placement_topic_scan_schedule_logic.go similarity index 100% rename from internal/logic/placement_topic/upsert_placement_topic_scan_schedule_logic.go rename to backend/internal/logic/placement_topic/upsert_placement_topic_scan_schedule_logic.go diff --git a/backend/internal/logic/scan_post/lookalike_logic.go b/backend/internal/logic/scan_post/lookalike_logic.go new file mode 100644 index 0000000..510b778 --- /dev/null +++ b/backend/internal/logic/scan_post/lookalike_logic.go @@ -0,0 +1,126 @@ +package scan_post + +import ( + "context" + "math" + "sort" + + "haixun-backend/internal/library/authctx" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type LookalikeLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewLookalikeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LookalikeLogic { + return &LookalikeLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *LookalikeLogic) Lookalike(req *types.LookalikeReq) (*types.LookalikeData, error) { + actor, ok := authctx.ActorFromContext(l.ctx) + if !ok { + return nil, app.For(code.Auth).AuthUnauthorized("missing actor") + } + + source, err := l.svcCtx.ScanPost.Get(l.ctx, actor.TenantID, actor.UID, req.BrandID, req.SourcePostID) + if err != nil { + return nil, err + } + if len(source.Embedding) == 0 { + return &types.LookalikeData{List: nil}, nil + } + + brandID := req.BrandID + if brandID == "" { + brandID = source.BrandID + } + + limit := req.Limit + if limit < 1 { + limit = 10 + } + if limit > 50 { + limit = 50 + } + + candidates, err := l.svcCtx.ScanPost.List(l.ctx, scanpostusecase.ListRequest{ + TenantID: actor.TenantID, + OwnerUID: actor.UID, + BrandID: brandID, + Limit: 200, + }) + if err != nil { + return nil, err + } + + type scored struct { + post scanpostusecase.ScanPostSummary + sim float64 + } + var scoredList []scored + for _, c := range candidates { + if c.ID == req.SourcePostID { + continue + } + if len(c.Embedding) == 0 { + continue + } + sim := cosineSimilarity(source.Embedding, c.Embedding) + if sim < 0.3 { + continue + } + scoredList = append(scoredList, scored{post: c, sim: sim}) + } + + sort.Slice(scoredList, func(i, j int) bool { + return scoredList[i].sim > scoredList[j].sim + }) + + if len(scoredList) > limit { + scoredList = scoredList[:limit] + } + + out := make([]types.LookalikeItemData, 0, len(scoredList)) + for _, s := range scoredList { + out = append(out, types.LookalikeItemData{ + PostID: s.post.ID, + AuthorID: s.post.AuthorID, + AuthorName: s.post.Author, + AuthorAvatar: s.post.AuthorAvatar, + Permalink: s.post.Permalink, + Text: s.post.Text, + PlacementScore: s.post.PlacementScore, + SemanticScore: s.post.SemanticScore, + EngagementPredicted: s.post.EngagementPredicted, + AudienceQuality: s.post.AudienceQualityScore, + Similarity: math.Round(s.sim*1000) / 1000, + }) + } + + return &types.LookalikeData{List: out}, nil +} + +func cosineSimilarity(a, b []float32) float64 { + if len(a) == 0 || len(b) == 0 || len(a) != len(b) { + return 0 + } + var dot, na, nb float64 + for i := range a { + dot += float64(a[i]) * float64(b[i]) + na += float64(a[i]) * float64(a[i]) + nb += float64(b[i]) * float64(b[i]) + } + if na == 0 || nb == 0 { + return 0 + } + return dot / (math.Sqrt(na) * math.Sqrt(nb)) +} diff --git a/backend/internal/logic/setting/authz.go b/backend/internal/logic/setting/authz.go new file mode 100644 index 0000000..471e440 --- /dev/null +++ b/backend/internal/logic/setting/authz.go @@ -0,0 +1,41 @@ +package setting + +import ( + "context" + + "haixun-backend/internal/library/authctx" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/svc" +) + +// authorizeSettingAccess guards the generic settings API against horizontal +// privilege escalation (IDOR). The settings collection stores sensitive values +// such as AI / Brave / Exa API keys under the "user" and "account" scopes, so +// the caller must be the owner of the scope being accessed. +// +// - "user": scope_id must equal the caller uid (uid is enumerable). +// - "account": the threads account must belong to the caller. +// - other scopes (brand / persona / placement_topic / copy_mission ...): +// keyed by unguessable ObjectIDs and hold non-secret research config; +// ownership is enforced by their own feature endpoints. We still require an +// authenticated actor here. +func authorizeSettingAccess(ctx context.Context, svcCtx *svc.ServiceContext, scope, scopeID string) error { + actor, ok := authctx.ActorFromContext(ctx) + if !ok || actor.UID == "" { + return app.For(code.Auth).AuthUnauthorized("missing actor") + } + + switch scope { + case "user": + if scopeID != actor.UID { + return app.For(code.Setting).AuthForbidden("cannot access another user's settings") + } + case "account": + if _, err := svcCtx.ThreadsAccount.Get(ctx, actor.TenantID, actor.UID, scopeID); err != nil { + return app.For(code.Setting).AuthForbidden("cannot access settings of an account you do not own") + } + } + + return nil +} diff --git a/internal/logic/setting/delete_setting_logic.go b/backend/internal/logic/setting/delete_setting_logic.go similarity index 82% rename from internal/logic/setting/delete_setting_logic.go rename to backend/internal/logic/setting/delete_setting_logic.go index 6eb1395..3113a69 100644 --- a/internal/logic/setting/delete_setting_logic.go +++ b/backend/internal/logic/setting/delete_setting_logic.go @@ -17,5 +17,8 @@ func NewDeleteSettingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Del } func (l *DeleteSettingLogic) DeleteSetting(req *types.SettingKeyPath) error { + if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil { + return err + } return l.svcCtx.Setting.Delete(l.ctx, req.Scope, req.ScopeID, req.Key) } diff --git a/internal/logic/setting/get_setting_logic.go b/backend/internal/logic/setting/get_setting_logic.go similarity index 84% rename from internal/logic/setting/get_setting_logic.go rename to backend/internal/logic/setting/get_setting_logic.go index eb5ab6d..3ea83d5 100644 --- a/internal/logic/setting/get_setting_logic.go +++ b/backend/internal/logic/setting/get_setting_logic.go @@ -17,6 +17,9 @@ func NewGetSettingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetSet } func (l *GetSettingLogic) GetSetting(req *types.SettingKeyPath) (*types.SettingData, error) { + if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil { + return nil, err + } item, err := l.svcCtx.Setting.Get(l.ctx, req.Scope, req.ScopeID, req.Key) if err != nil { return nil, err diff --git a/internal/logic/setting/list_settings_logic.go b/backend/internal/logic/setting/list_settings_logic.go similarity index 90% rename from internal/logic/setting/list_settings_logic.go rename to backend/internal/logic/setting/list_settings_logic.go index da5b2cc..f63fdb8 100644 --- a/internal/logic/setting/list_settings_logic.go +++ b/backend/internal/logic/setting/list_settings_logic.go @@ -17,6 +17,9 @@ func NewListSettingsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *List } func (l *ListSettingsLogic) ListSettings(req *types.SettingPath) (*types.SettingListData, error) { + if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil { + return nil, err + } items, total, page, pageSize, err := l.svcCtx.Setting.List(l.ctx, req.Scope, req.ScopeID, req.Page, req.PageSize) if err != nil { return nil, err diff --git a/internal/logic/setting/mapper.go b/backend/internal/logic/setting/mapper.go similarity index 100% rename from internal/logic/setting/mapper.go rename to backend/internal/logic/setting/mapper.go diff --git a/internal/logic/setting/upsert_setting_logic.go b/backend/internal/logic/setting/upsert_setting_logic.go similarity index 87% rename from internal/logic/setting/upsert_setting_logic.go rename to backend/internal/logic/setting/upsert_setting_logic.go index d30547d..c9a2cee 100644 --- a/internal/logic/setting/upsert_setting_logic.go +++ b/backend/internal/logic/setting/upsert_setting_logic.go @@ -18,6 +18,9 @@ func NewUpsertSettingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Ups } func (l *UpsertSettingLogic) UpsertSetting(req *types.SettingUpsertReq) (*types.SettingData, error) { + if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil { + return nil, err + } item, err := l.svcCtx.Setting.Upsert(l.ctx, settinguc.UpsertRequest{ Scope: req.Scope, ScopeID: req.ScopeID, diff --git a/internal/logic/threads_account/activate_threads_account_logic.go b/backend/internal/logic/threads_account/activate_threads_account_logic.go similarity index 100% rename from internal/logic/threads_account/activate_threads_account_logic.go rename to backend/internal/logic/threads_account/activate_threads_account_logic.go diff --git a/internal/logic/threads_account/actor.go b/backend/internal/logic/threads_account/actor.go similarity index 100% rename from internal/logic/threads_account/actor.go rename to backend/internal/logic/threads_account/actor.go diff --git a/internal/logic/threads_account/create_threads_account_logic.go b/backend/internal/logic/threads_account/create_threads_account_logic.go similarity index 100% rename from internal/logic/threads_account/create_threads_account_logic.go rename to backend/internal/logic/threads_account/create_threads_account_logic.go diff --git a/internal/logic/threads_account/get_threads_account_ai_settings_logic.go b/backend/internal/logic/threads_account/get_threads_account_ai_settings_logic.go similarity index 100% rename from internal/logic/threads_account/get_threads_account_ai_settings_logic.go rename to backend/internal/logic/threads_account/get_threads_account_ai_settings_logic.go diff --git a/internal/logic/threads_account/get_threads_account_connection_logic.go b/backend/internal/logic/threads_account/get_threads_account_connection_logic.go similarity index 100% rename from internal/logic/threads_account/get_threads_account_connection_logic.go rename to backend/internal/logic/threads_account/get_threads_account_connection_logic.go diff --git a/internal/logic/threads_account/get_threads_account_logic.go b/backend/internal/logic/threads_account/get_threads_account_logic.go similarity index 100% rename from internal/logic/threads_account/get_threads_account_logic.go rename to backend/internal/logic/threads_account/get_threads_account_logic.go diff --git a/internal/logic/threads_account/import_threads_account_session_logic.go b/backend/internal/logic/threads_account/import_threads_account_session_logic.go similarity index 100% rename from internal/logic/threads_account/import_threads_account_session_logic.go rename to backend/internal/logic/threads_account/import_threads_account_session_logic.go diff --git a/backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go b/backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go new file mode 100644 index 0000000..41556bb --- /dev/null +++ b/backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go @@ -0,0 +1,58 @@ +package threads_account + +import ( + "context" + + "haixun-backend/internal/model/ai/domain/enum" + aiuc "haixun-backend/internal/model/ai/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListThreadsAccountAiProviderModelsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListThreadsAccountAiProviderModelsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListThreadsAccountAiProviderModelsLogic { + return &ListThreadsAccountAiProviderModelsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListThreadsAccountAiProviderModelsLogic) ListThreadsAccountAiProviderModels( + req *types.ThreadsAccountAiProviderModelsHandlerReq, +) (*types.ThreadsAccountAiProviderModelsData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + apiKey, err := l.svcCtx.ThreadsAccount.ResolveAiProviderAPIKey( + l.ctx, + tenantID, + uid, + req.ID, + req.Provider, + req.ApiKey, + ) + if err != nil { + return nil, err + } + result := l.svcCtx.AI.ListProviderModels( + l.ctx, + enum.ProviderID(req.Provider), + aiuc.Credential{APIKey: apiKey}, + ) + return &types.ThreadsAccountAiProviderModelsData{ + ID: result.ID, + Label: result.Label, + Models: result.Models, + Streams: result.Streams, + Error: result.Error, + }, nil +} diff --git a/internal/logic/threads_account/list_threads_accounts_logic.go b/backend/internal/logic/threads_account/list_threads_accounts_logic.go similarity index 100% rename from internal/logic/threads_account/list_threads_accounts_logic.go rename to backend/internal/logic/threads_account/list_threads_accounts_logic.go diff --git a/internal/logic/threads_account/mapper.go b/backend/internal/logic/threads_account/mapper.go similarity index 100% rename from internal/logic/threads_account/mapper.go rename to backend/internal/logic/threads_account/mapper.go diff --git a/internal/logic/threads_account/update_threads_account_ai_settings_logic.go b/backend/internal/logic/threads_account/update_threads_account_ai_settings_logic.go similarity index 100% rename from internal/logic/threads_account/update_threads_account_ai_settings_logic.go rename to backend/internal/logic/threads_account/update_threads_account_ai_settings_logic.go diff --git a/internal/logic/threads_account/update_threads_account_connection_logic.go b/backend/internal/logic/threads_account/update_threads_account_connection_logic.go similarity index 100% rename from internal/logic/threads_account/update_threads_account_connection_logic.go rename to backend/internal/logic/threads_account/update_threads_account_connection_logic.go diff --git a/internal/logic/threads_account/update_threads_account_logic.go b/backend/internal/logic/threads_account/update_threads_account_logic.go similarity index 100% rename from internal/logic/threads_account/update_threads_account_logic.go rename to backend/internal/logic/threads_account/update_threads_account_logic.go diff --git a/internal/middleware/auth.go b/backend/internal/middleware/auth.go similarity index 100% rename from internal/middleware/auth.go rename to backend/internal/middleware/auth.go diff --git a/internal/middleware/auth_test.go b/backend/internal/middleware/auth_test.go similarity index 100% rename from internal/middleware/auth_test.go rename to backend/internal/middleware/auth_test.go diff --git a/internal/middleware/authjwt_middleware.go b/backend/internal/middleware/authjwt_middleware.go similarity index 100% rename from internal/middleware/authjwt_middleware.go rename to backend/internal/middleware/authjwt_middleware.go diff --git a/internal/middleware/memberauth_middleware.go b/backend/internal/middleware/memberauth_middleware.go similarity index 100% rename from internal/middleware/memberauth_middleware.go rename to backend/internal/middleware/memberauth_middleware.go diff --git a/internal/middleware/permissionrbac_middleware.go b/backend/internal/middleware/permissionrbac_middleware.go similarity index 78% rename from internal/middleware/permissionrbac_middleware.go rename to backend/internal/middleware/permissionrbac_middleware.go index 565655a..870afbc 100644 --- a/internal/middleware/permissionrbac_middleware.go +++ b/backend/internal/middleware/permissionrbac_middleware.go @@ -13,7 +13,14 @@ import ( ) // PermissionRBACMiddleware enforces catalog permissions for authenticated members. -// Mount after AuthJWT or MemberAuth so actor is present in context. +// It is composed AFTER AuthJWT / MemberAuth (see svc.NewServiceContext) so the +// actor is already in context. +// +// Route coverage: defaultPermissions uses trailing-* patterns +// (/api/v1/brands/*, /api/v1/personas/*, /api/v1/placement/topics/*, ...) that +// cover nested routes (copy-missions, scan-posts, knowledge-graph, etc.). +// permission.Me also falls back to the default user permission set when a tenant +// has no seeded role_permissions, so members are never locked out by a missing seed. type PermissionRBACMiddleware struct { members memberdomain.UseCase permissions permissiondomain.UseCase diff --git a/internal/middleware/workersecret_middleware.go b/backend/internal/middleware/workersecret_middleware.go similarity index 61% rename from internal/middleware/workersecret_middleware.go rename to backend/internal/middleware/workersecret_middleware.go index ad59b61..02995f2 100644 --- a/internal/middleware/workersecret_middleware.go +++ b/backend/internal/middleware/workersecret_middleware.go @@ -1,6 +1,7 @@ package middleware import ( + "crypto/subtle" "net/http" "strings" @@ -12,10 +13,10 @@ import ( const WorkerSecretHeader = "X-Worker-Secret" -// WorkerSecretMiddleware enforces X-Worker-Secret on internal worker routes when -// InternalWorker.Secret is configured. Mounted via @server(middleware: WorkerSecret) -// in generate/api/worker_internal.api. When the secret is empty it passes through, -// preserving local-dev behaviour. +// WorkerSecretMiddleware enforces X-Worker-Secret on internal worker routes. +// The secret is REQUIRED: when InternalWorker.Secret is empty the middleware +// rejects every request (fail closed) instead of passing through, so the +// internal worker endpoints can never be exposed unauthenticated. type WorkerSecretMiddleware struct { cfg config.InternalWorkerConf } @@ -27,7 +28,12 @@ func NewWorkerSecretMiddleware(cfg config.InternalWorkerConf) *WorkerSecretMiddl func (m *WorkerSecretMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { secret := strings.TrimSpace(m.cfg.Secret) - if secret != "" && r.Header.Get(WorkerSecretHeader) != secret { + if secret == "" { + response.Write(r.Context(), w, nil, app.For(code.Auth).AuthForbidden("internal worker secret is not configured")) + return + } + provided := r.Header.Get(WorkerSecretHeader) + if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 { response.Write(r.Context(), w, nil, app.For(code.Auth).AuthUnauthorized("invalid worker secret")) return } diff --git a/internal/model/ai/domain/enum/provider.go b/backend/internal/model/ai/domain/enum/provider.go similarity index 100% rename from internal/model/ai/domain/enum/provider.go rename to backend/internal/model/ai/domain/enum/provider.go diff --git a/internal/model/ai/domain/usecase/ai.go b/backend/internal/model/ai/domain/usecase/ai.go similarity index 100% rename from internal/model/ai/domain/usecase/ai.go rename to backend/internal/model/ai/domain/usecase/ai.go diff --git a/internal/model/ai/provider/openai_compatible.go b/backend/internal/model/ai/provider/openai_compatible.go similarity index 100% rename from internal/model/ai/provider/openai_compatible.go rename to backend/internal/model/ai/provider/openai_compatible.go diff --git a/internal/model/ai/provider/openai_compatible_test.go b/backend/internal/model/ai/provider/openai_compatible_test.go similarity index 100% rename from internal/model/ai/provider/openai_compatible_test.go rename to backend/internal/model/ai/provider/openai_compatible_test.go diff --git a/internal/model/ai/usecase/provider_map.go b/backend/internal/model/ai/usecase/provider_map.go similarity index 100% rename from internal/model/ai/usecase/provider_map.go rename to backend/internal/model/ai/usecase/provider_map.go diff --git a/internal/model/ai/usecase/usecase.go b/backend/internal/model/ai/usecase/usecase.go similarity index 100% rename from internal/model/ai/usecase/usecase.go rename to backend/internal/model/ai/usecase/usecase.go diff --git a/internal/model/auth/domain/repository/token_revoke.go b/backend/internal/model/auth/domain/repository/token_revoke.go similarity index 100% rename from internal/model/auth/domain/repository/token_revoke.go rename to backend/internal/model/auth/domain/repository/token_revoke.go diff --git a/internal/model/auth/domain/usecase/token.go b/backend/internal/model/auth/domain/usecase/token.go similarity index 100% rename from internal/model/auth/domain/usecase/token.go rename to backend/internal/model/auth/domain/usecase/token.go diff --git a/internal/model/auth/repository/token_revoke_redis.go b/backend/internal/model/auth/repository/token_revoke_redis.go similarity index 100% rename from internal/model/auth/repository/token_revoke_redis.go rename to backend/internal/model/auth/repository/token_revoke_redis.go diff --git a/internal/model/auth/usecase/token.go b/backend/internal/model/auth/usecase/token.go similarity index 95% rename from internal/model/auth/usecase/token.go rename to backend/internal/model/auth/usecase/token.go index ca1e040..efb27e9 100644 --- a/internal/model/auth/usecase/token.go +++ b/backend/internal/model/auth/usecase/token.go @@ -23,6 +23,12 @@ type tokenUseCase struct { func NewTokenUseCase(cfg config.AuthConf, revoke domrepo.TokenRevokeStore) domusecase.TokenUseCase { cfg = normalizeConfig(cfg) + if cfg.AccessSecret == "" || cfg.RefreshSecret == "" { + // Fail fast: never fall back to a known/hardcoded secret, otherwise tokens + // could be forged. Provide secrets via env (HAIXUN_JWT_ACCESS_SECRET / + // HAIXUN_JWT_REFRESH_SECRET) or the dev config file. + panic("auth: AccessSecret and RefreshSecret must be configured") + } return &tokenUseCase{cfg: cfg, revoke: revoke} } @@ -210,12 +216,6 @@ func normalizeConfig(cfg config.AuthConf) config.AuthConf { if cfg.RefreshExpireSeconds <= 0 { cfg.RefreshExpireSeconds = 2592000 } - if cfg.AccessSecret == "" { - cfg.AccessSecret = "haixun-dev-access-secret-change-me" - } - if cfg.RefreshSecret == "" { - cfg.RefreshSecret = "haixun-dev-refresh-secret-change-me" - } return cfg } diff --git a/internal/model/brand/domain/entity/brand.go b/backend/internal/model/brand/domain/entity/brand.go similarity index 100% rename from internal/model/brand/domain/entity/brand.go rename to backend/internal/model/brand/domain/entity/brand.go diff --git a/internal/model/brand/domain/entity/product.go b/backend/internal/model/brand/domain/entity/product.go similarity index 100% rename from internal/model/brand/domain/entity/product.go rename to backend/internal/model/brand/domain/entity/product.go diff --git a/internal/model/brand/domain/entity/research_map.go b/backend/internal/model/brand/domain/entity/research_map.go similarity index 100% rename from internal/model/brand/domain/entity/research_map.go rename to backend/internal/model/brand/domain/entity/research_map.go diff --git a/internal/model/brand/domain/repository/repository.go b/backend/internal/model/brand/domain/repository/repository.go similarity index 100% rename from internal/model/brand/domain/repository/repository.go rename to backend/internal/model/brand/domain/repository/repository.go diff --git a/internal/model/brand/domain/usecase/usecase.go b/backend/internal/model/brand/domain/usecase/usecase.go similarity index 100% rename from internal/model/brand/domain/usecase/usecase.go rename to backend/internal/model/brand/domain/usecase/usecase.go diff --git a/internal/model/brand/repository/mongo.go b/backend/internal/model/brand/repository/mongo.go similarity index 100% rename from internal/model/brand/repository/mongo.go rename to backend/internal/model/brand/repository/mongo.go diff --git a/internal/model/brand/usecase/products.go b/backend/internal/model/brand/usecase/products.go similarity index 100% rename from internal/model/brand/usecase/products.go rename to backend/internal/model/brand/usecase/products.go diff --git a/internal/model/brand/usecase/usecase.go b/backend/internal/model/brand/usecase/usecase.go similarity index 100% rename from internal/model/brand/usecase/usecase.go rename to backend/internal/model/brand/usecase/usecase.go diff --git a/internal/model/content_matrix/domain/entity/matrix.go b/backend/internal/model/content_matrix/domain/entity/matrix.go similarity index 100% rename from internal/model/content_matrix/domain/entity/matrix.go rename to backend/internal/model/content_matrix/domain/entity/matrix.go diff --git a/internal/model/content_matrix/domain/repository/repository.go b/backend/internal/model/content_matrix/domain/repository/repository.go similarity index 100% rename from internal/model/content_matrix/domain/repository/repository.go rename to backend/internal/model/content_matrix/domain/repository/repository.go diff --git a/internal/model/content_matrix/domain/usecase/usecase.go b/backend/internal/model/content_matrix/domain/usecase/usecase.go similarity index 100% rename from internal/model/content_matrix/domain/usecase/usecase.go rename to backend/internal/model/content_matrix/domain/usecase/usecase.go diff --git a/internal/model/content_matrix/repository/mongo.go b/backend/internal/model/content_matrix/repository/mongo.go similarity index 100% rename from internal/model/content_matrix/repository/mongo.go rename to backend/internal/model/content_matrix/repository/mongo.go diff --git a/internal/model/content_matrix/usecase/usecase.go b/backend/internal/model/content_matrix/usecase/usecase.go similarity index 100% rename from internal/model/content_matrix/usecase/usecase.go rename to backend/internal/model/content_matrix/usecase/usecase.go diff --git a/internal/model/copy_draft/domain/entity/draft.go b/backend/internal/model/copy_draft/domain/entity/draft.go similarity index 100% rename from internal/model/copy_draft/domain/entity/draft.go rename to backend/internal/model/copy_draft/domain/entity/draft.go diff --git a/internal/model/copy_draft/domain/repository/repository.go b/backend/internal/model/copy_draft/domain/repository/repository.go similarity index 100% rename from internal/model/copy_draft/domain/repository/repository.go rename to backend/internal/model/copy_draft/domain/repository/repository.go diff --git a/internal/model/copy_draft/domain/usecase/usecase.go b/backend/internal/model/copy_draft/domain/usecase/usecase.go similarity index 100% rename from internal/model/copy_draft/domain/usecase/usecase.go rename to backend/internal/model/copy_draft/domain/usecase/usecase.go diff --git a/internal/model/copy_draft/repository/mongo.go b/backend/internal/model/copy_draft/repository/mongo.go similarity index 100% rename from internal/model/copy_draft/repository/mongo.go rename to backend/internal/model/copy_draft/repository/mongo.go diff --git a/internal/model/copy_draft/usecase/usecase.go b/backend/internal/model/copy_draft/usecase/usecase.go similarity index 100% rename from internal/model/copy_draft/usecase/usecase.go rename to backend/internal/model/copy_draft/usecase/usecase.go diff --git a/internal/model/copy_mission/domain/entity/mission.go b/backend/internal/model/copy_mission/domain/entity/mission.go similarity index 100% rename from internal/model/copy_mission/domain/entity/mission.go rename to backend/internal/model/copy_mission/domain/entity/mission.go diff --git a/internal/model/copy_mission/domain/repository/repository.go b/backend/internal/model/copy_mission/domain/repository/repository.go similarity index 100% rename from internal/model/copy_mission/domain/repository/repository.go rename to backend/internal/model/copy_mission/domain/repository/repository.go diff --git a/internal/model/copy_mission/domain/usecase/usecase.go b/backend/internal/model/copy_mission/domain/usecase/usecase.go similarity index 100% rename from internal/model/copy_mission/domain/usecase/usecase.go rename to backend/internal/model/copy_mission/domain/usecase/usecase.go diff --git a/internal/model/copy_mission/repository/mongo.go b/backend/internal/model/copy_mission/repository/mongo.go similarity index 100% rename from internal/model/copy_mission/repository/mongo.go rename to backend/internal/model/copy_mission/repository/mongo.go diff --git a/internal/model/copy_mission/usecase/usecase.go b/backend/internal/model/copy_mission/usecase/usecase.go similarity index 100% rename from internal/model/copy_mission/usecase/usecase.go rename to backend/internal/model/copy_mission/usecase/usecase.go diff --git a/backend/internal/model/crm_contact/domain/entity/contact.go b/backend/internal/model/crm_contact/domain/entity/contact.go new file mode 100644 index 0000000..d18dc13 --- /dev/null +++ b/backend/internal/model/crm_contact/domain/entity/contact.go @@ -0,0 +1,30 @@ +package entity + +const CollectionName = "crm_contacts" + +const ( + ContactStatusLead = "lead" + ContactStatusContacted = "contacted" + ContactStatusResponded = "responded" + ContactStatusConverted = "converted" + ContactStatusArchived = "archived" +) + +type CRMContact struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + BrandID string `bson:"brand_id,omitempty"` + AuthorID string `bson:"author_id"` + AuthorName string `bson:"author_name"` + AuthorAvatar string `bson:"author_avatar,omitempty"` + AuthorFollowers int `bson:"author_followers,omitempty"` + ScanPostID string `bson:"scan_post_id,omitempty"` + Notes string `bson:"notes,omitempty"` + Tags []string `bson:"tags,omitempty"` + Status string `bson:"status"` + OutreachCount int `bson:"outreach_count,omitempty"` + LastContactedAt int64 `bson:"last_contacted_at,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} diff --git a/backend/internal/model/crm_contact/domain/repository/repository.go b/backend/internal/model/crm_contact/domain/repository/repository.go new file mode 100644 index 0000000..b9f0797 --- /dev/null +++ b/backend/internal/model/crm_contact/domain/repository/repository.go @@ -0,0 +1,25 @@ +package repository + +import ( + "context" + "haixun-backend/internal/model/crm_contact/domain/entity" +) + +type ListFilter struct { + BrandID string + Status string + Tag string + Limit int + Offset int +} + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Create(ctx context.Context, contact *entity.CRMContact) error + Get(ctx context.Context, tenantID, ownerUID, contactID string) (*entity.CRMContact, error) + GetByAuthor(ctx context.Context, tenantID, ownerUID, authorID string) (*entity.CRMContact, error) + Update(ctx context.Context, tenantID, ownerUID, contactID string, patch map[string]interface{}) (*entity.CRMContact, error) + Delete(ctx context.Context, tenantID, ownerUID, contactID string) error + List(ctx context.Context, tenantID, ownerUID string, filter ListFilter) ([]entity.CRMContact, int, error) + UpsertByAuthor(ctx context.Context, contact *entity.CRMContact) (*entity.CRMContact, error) +} diff --git a/backend/internal/model/crm_contact/domain/usecase/usecase.go b/backend/internal/model/crm_contact/domain/usecase/usecase.go new file mode 100644 index 0000000..4cd8c96 --- /dev/null +++ b/backend/internal/model/crm_contact/domain/usecase/usecase.go @@ -0,0 +1,47 @@ +package usecase + +import "context" + +type CRMContactSummary struct { + ID string + BrandID string + AuthorID string + AuthorName string + AuthorAvatar string + AuthorFollowers int + ScanPostID string + Notes string + Tags []string + Status string + OutreachCount int + LastContactedAt int64 + CreateAt int64 + UpdateAt int64 +} + +type ListRequest struct { + TenantID string + OwnerUID string + BrandID string + Status string + Tag string + Page int + PageSize int +} + +type UpdateRequest struct { + TenantID string + OwnerUID string + ContactID string + Notes *string + Tags *[]string + Status *string +} + +type UseCase interface { + Create(ctx context.Context, tenantID, ownerUID, brandID, authorID, authorName string) (*CRMContactSummary, error) + Get(ctx context.Context, tenantID, ownerUID, contactID string) (*CRMContactSummary, error) + Update(ctx context.Context, req UpdateRequest) (*CRMContactSummary, error) + Delete(ctx context.Context, tenantID, ownerUID, contactID string) error + List(ctx context.Context, req ListRequest) ([]CRMContactSummary, int, error) +} diff --git a/backend/internal/model/crm_contact/repository/mongo.go b/backend/internal/model/crm_contact/repository/mongo.go new file mode 100644 index 0000000..101400e --- /dev/null +++ b/backend/internal/model/crm_contact/repository/mongo.go @@ -0,0 +1,217 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/crm_contact/domain/entity" + domrepo "haixun-backend/internal/model/crm_contact/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func baseFilter(tenantID, ownerUID string) bson.M { + return bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + } +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "brand_id", Value: 1}, + {Key: "create_at", Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "author_id", Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + }) + return err +} + +func (r *mongoRepository) Create(ctx context.Context, contact *entity.CRMContact) error { + if r.collection == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + if contact == nil { + return app.For(code.Persona).InputMissingRequired("contact is required") + } + _, err := r.collection.InsertOne(ctx, contact) + return err +} + +func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, contactID string) (*entity.CRMContact, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + contactID = strings.TrimSpace(contactID) + if contactID == "" { + return nil, app.For(code.Persona).InputMissingRequired("contact_id is required") + } + filter := baseFilter(tenantID, ownerUID) + filter["_id"] = contactID + var out entity.CRMContact + err := r.collection.FindOne(ctx, filter).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Persona).ResNotFound("crm contact not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) GetByAuthor(ctx context.Context, tenantID, ownerUID, authorID string) (*entity.CRMContact, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + authorID = strings.TrimSpace(authorID) + if authorID == "" { + return nil, app.For(code.Persona).InputMissingRequired("author_id is required") + } + filter := baseFilter(tenantID, ownerUID) + filter["author_id"] = authorID + var out entity.CRMContact + err := r.collection.FindOne(ctx, filter).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Persona).ResNotFound("crm contact not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) Update(ctx context.Context, tenantID, ownerUID, contactID string, patch map[string]interface{}) (*entity.CRMContact, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + if len(patch) == 0 { + return nil, app.For(code.Persona).InputMissingRequired("patch is required") + } + contactID = strings.TrimSpace(contactID) + filter := baseFilter(tenantID, ownerUID) + filter["_id"] = contactID + opts := options.FindOneAndUpdate().SetReturnDocument(options.After) + var out entity.CRMContact + err := r.collection.FindOneAndUpdate(ctx, filter, bson.M{"$set": patch}, opts).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Persona).ResNotFound("crm contact not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, contactID string) error { + if r.collection == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + contactID = strings.TrimSpace(contactID) + if contactID == "" { + return app.For(code.Persona).InputMissingRequired("contact_id is required") + } + filter := baseFilter(tenantID, ownerUID) + filter["_id"] = contactID + _, err := r.collection.DeleteOne(ctx, filter) + return err +} + +func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, filter domrepo.ListFilter) ([]entity.CRMContact, int, error) { + if r.collection == nil { + return nil, 0, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + query := baseFilter(tenantID, ownerUID) + if filter.BrandID != "" { + query["brand_id"] = filter.BrandID + } + if filter.Status != "" { + query["status"] = filter.Status + } + if filter.Tag != "" { + query["tags"] = filter.Tag + } + + limit := filter.Limit + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + skip := filter.Offset + if skip < 0 { + skip = 0 + } + + total, err := r.collection.CountDocuments(ctx, query) + if err != nil { + return nil, 0, err + } + + opts := options.Find(). + SetSort(bson.D{{Key: "create_at", Value: -1}}). + SetSkip(int64(skip)). + SetLimit(int64(limit)) + cur, err := r.collection.Find(ctx, query, opts) + if err != nil { + return nil, 0, err + } + defer cur.Close(ctx) + + var out []entity.CRMContact + if err := cur.All(ctx, &out); err != nil { + return nil, 0, err + } + return out, int(total), nil +} + +func (r *mongoRepository) UpsertByAuthor(ctx context.Context, contact *entity.CRMContact) (*entity.CRMContact, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + if contact == nil { + return nil, app.For(code.Persona).InputMissingRequired("contact is required") + } + filter := baseFilter(contact.TenantID, contact.OwnerUID) + filter["author_id"] = contact.AuthorID + update := bson.M{"$set": contact} + opts := options.FindOneAndUpdate(). + SetUpsert(true). + SetReturnDocument(options.After) + var out entity.CRMContact + err := r.collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&out) + if err != nil { + return nil, err + } + return &out, nil +} diff --git a/backend/internal/model/crm_contact/usecase/usecase.go b/backend/internal/model/crm_contact/usecase/usecase.go new file mode 100644 index 0000000..e0f6ed0 --- /dev/null +++ b/backend/internal/model/crm_contact/usecase/usecase.go @@ -0,0 +1,176 @@ +package usecase + +import ( + "context" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/crm_contact/domain/entity" + domrepo "haixun-backend/internal/model/crm_contact/domain/repository" + domusecase "haixun-backend/internal/model/crm_contact/domain/usecase" + + "github.com/google/uuid" +) + +type crmContactUseCase struct { + repo domrepo.Repository +} + +func NewUseCase(repo domrepo.Repository) domusecase.UseCase { + return &crmContactUseCase{repo: repo} +} + +func (u *crmContactUseCase) Create(ctx context.Context, tenantID, ownerUID, brandID, authorID, authorName string) (*domusecase.CRMContactSummary, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + authorID = strings.TrimSpace(authorID) + if authorID == "" { + return nil, app.For(code.Persona).InputMissingRequired("author_id is required") + } + authorName = strings.TrimSpace(authorName) + if authorName == "" { + return nil, app.For(code.Persona).InputMissingRequired("author_name is required") + } + now := clock.NowUnixNano() + contact := &entity.CRMContact{ + ID: uuid.NewString(), + TenantID: tenantID, + OwnerUID: ownerUID, + BrandID: strings.TrimSpace(brandID), + AuthorID: authorID, + AuthorName: authorName, + Status: entity.ContactStatusLead, + CreateAt: now, + UpdateAt: now, + } + if err := u.repo.Create(ctx, contact); err != nil { + return nil, err + } + summary := toSummary(*contact) + return &summary, nil +} + +func (u *crmContactUseCase) Get(ctx context.Context, tenantID, ownerUID, contactID string) (*domusecase.CRMContactSummary, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + contactID = strings.TrimSpace(contactID) + if contactID == "" { + return nil, app.For(code.Persona).InputMissingRequired("contact_id is required") + } + item, err := u.repo.Get(ctx, tenantID, ownerUID, contactID) + if err != nil { + return nil, err + } + summary := toSummary(*item) + return &summary, nil +} + +func (u *crmContactUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.CRMContactSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID); err != nil { + return nil, err + } + contactID := strings.TrimSpace(req.ContactID) + if contactID == "" { + return nil, app.For(code.Persona).InputMissingRequired("contact_id is required") + } + patch := map[string]interface{}{} + if req.Notes != nil { + patch["notes"] = strings.TrimSpace(*req.Notes) + } + if req.Tags != nil { + patch["tags"] = *req.Tags + } + if req.Status != nil { + status := strings.TrimSpace(*req.Status) + if status != "" && status != entity.ContactStatusLead && status != entity.ContactStatusContacted && status != entity.ContactStatusResponded && status != entity.ContactStatusConverted && status != entity.ContactStatusArchived { + return nil, app.For(code.Persona).InputInvalidFormat("invalid contact status") + } + if status != "" { + patch["status"] = status + } + } + if len(patch) == 0 { + return nil, app.For(code.Persona).InputMissingRequired("no fields to update") + } + patch["update_at"] = clock.NowUnixNano() + item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, contactID, patch) + if err != nil { + return nil, err + } + summary := toSummary(*item) + return &summary, nil +} + +func (u *crmContactUseCase) Delete(ctx context.Context, tenantID, ownerUID, contactID string) error { + if err := requireActor(tenantID, ownerUID); err != nil { + return err + } + contactID = strings.TrimSpace(contactID) + if contactID == "" { + return app.For(code.Persona).InputMissingRequired("contact_id is required") + } + return u.repo.Delete(ctx, tenantID, ownerUID, contactID) +} + +func (u *crmContactUseCase) List(ctx context.Context, req domusecase.ListRequest) ([]domusecase.CRMContactSummary, int, error) { + if err := requireActor(req.TenantID, req.OwnerUID); err != nil { + return nil, 0, err + } + page := req.Page + pageSize := req.PageSize + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + filter := domrepo.ListFilter{ + BrandID: strings.TrimSpace(req.BrandID), + Status: strings.TrimSpace(req.Status), + Tag: strings.TrimSpace(req.Tag), + Limit: pageSize, + Offset: (page - 1) * pageSize, + } + items, total, err := u.repo.List(ctx, req.TenantID, req.OwnerUID, filter) + if err != nil { + return nil, 0, err + } + out := make([]domusecase.CRMContactSummary, 0, len(items)) + for _, item := range items { + out = append(out, toSummary(item)) + } + return out, total, nil +} + +func requireActor(tenantID, ownerUID string) error { + if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { + return app.For(code.Auth).AuthUnauthorized("missing actor") + } + return nil +} + +func toSummary(item entity.CRMContact) domusecase.CRMContactSummary { + return domusecase.CRMContactSummary{ + ID: item.ID, + BrandID: item.BrandID, + AuthorID: item.AuthorID, + AuthorName: item.AuthorName, + AuthorAvatar: item.AuthorAvatar, + AuthorFollowers: item.AuthorFollowers, + ScanPostID: item.ScanPostID, + Notes: item.Notes, + Tags: item.Tags, + Status: item.Status, + OutreachCount: item.OutreachCount, + LastContactedAt: item.LastContactedAt, + CreateAt: item.CreateAt, + UpdateAt: item.UpdateAt, + } +} diff --git a/internal/model/job/cron/next_run.go b/backend/internal/model/job/cron/next_run.go similarity index 100% rename from internal/model/job/cron/next_run.go rename to backend/internal/model/job/cron/next_run.go diff --git a/internal/model/job/cron/next_run_test.go b/backend/internal/model/job/cron/next_run_test.go similarity index 100% rename from internal/model/job/cron/next_run_test.go rename to backend/internal/model/job/cron/next_run_test.go diff --git a/internal/model/job/domain/entity/event.go b/backend/internal/model/job/domain/entity/event.go similarity index 100% rename from internal/model/job/domain/entity/event.go rename to backend/internal/model/job/domain/entity/event.go diff --git a/internal/model/job/domain/entity/run.go b/backend/internal/model/job/domain/entity/run.go similarity index 100% rename from internal/model/job/domain/entity/run.go rename to backend/internal/model/job/domain/entity/run.go diff --git a/internal/model/job/domain/entity/schedule.go b/backend/internal/model/job/domain/entity/schedule.go similarity index 100% rename from internal/model/job/domain/entity/schedule.go rename to backend/internal/model/job/domain/entity/schedule.go diff --git a/internal/model/job/domain/entity/template.go b/backend/internal/model/job/domain/entity/template.go similarity index 100% rename from internal/model/job/domain/entity/template.go rename to backend/internal/model/job/domain/entity/template.go diff --git a/internal/model/job/domain/enum/status.go b/backend/internal/model/job/domain/enum/status.go similarity index 100% rename from internal/model/job/domain/enum/status.go rename to backend/internal/model/job/domain/enum/status.go diff --git a/internal/model/job/domain/repository/event.go b/backend/internal/model/job/domain/repository/event.go similarity index 100% rename from internal/model/job/domain/repository/event.go rename to backend/internal/model/job/domain/repository/event.go diff --git a/internal/model/job/domain/repository/queue.go b/backend/internal/model/job/domain/repository/queue.go similarity index 100% rename from internal/model/job/domain/repository/queue.go rename to backend/internal/model/job/domain/repository/queue.go diff --git a/internal/model/job/domain/repository/run.go b/backend/internal/model/job/domain/repository/run.go similarity index 100% rename from internal/model/job/domain/repository/run.go rename to backend/internal/model/job/domain/repository/run.go diff --git a/internal/model/job/domain/repository/schedule.go b/backend/internal/model/job/domain/repository/schedule.go similarity index 100% rename from internal/model/job/domain/repository/schedule.go rename to backend/internal/model/job/domain/repository/schedule.go diff --git a/internal/model/job/domain/repository/template.go b/backend/internal/model/job/domain/repository/template.go similarity index 100% rename from internal/model/job/domain/repository/template.go rename to backend/internal/model/job/domain/repository/template.go diff --git a/internal/model/job/domain/usecase/job.go b/backend/internal/model/job/domain/usecase/job.go similarity index 97% rename from internal/model/job/domain/usecase/job.go rename to backend/internal/model/job/domain/usecase/job.go index 6be688b..55564a0 100644 --- a/internal/model/job/domain/usecase/job.go +++ b/backend/internal/model/job/domain/usecase/job.go @@ -98,6 +98,8 @@ type UseCase interface { EnsureAnalyzeCopyMissionTemplate(ctx context.Context) error EnsureGenerateCopyMatrixTemplate(ctx context.Context) error EnsureGenerateCopyDraftTemplate(ctx context.Context) error + EnsureRefreshThreadsTokenTemplate(ctx context.Context) error + EnsurePublishAnalyticsTemplate(ctx context.Context) error CreateRun(ctx context.Context, req CreateRunRequest) (*entity.Run, error) GetRun(ctx context.Context, jobID string) (*entity.Run, error) diff --git a/internal/model/job/repository/mongo_event.go b/backend/internal/model/job/repository/mongo_event.go similarity index 100% rename from internal/model/job/repository/mongo_event.go rename to backend/internal/model/job/repository/mongo_event.go diff --git a/internal/model/job/repository/mongo_run.go b/backend/internal/model/job/repository/mongo_run.go similarity index 100% rename from internal/model/job/repository/mongo_run.go rename to backend/internal/model/job/repository/mongo_run.go diff --git a/internal/model/job/repository/mongo_schedule.go b/backend/internal/model/job/repository/mongo_schedule.go similarity index 100% rename from internal/model/job/repository/mongo_schedule.go rename to backend/internal/model/job/repository/mongo_schedule.go diff --git a/internal/model/job/repository/mongo_template.go b/backend/internal/model/job/repository/mongo_template.go similarity index 100% rename from internal/model/job/repository/mongo_template.go rename to backend/internal/model/job/repository/mongo_template.go diff --git a/internal/model/job/repository/redis_queue.go b/backend/internal/model/job/repository/redis_queue.go similarity index 100% rename from internal/model/job/repository/redis_queue.go rename to backend/internal/model/job/repository/redis_queue.go diff --git a/internal/model/job/repository/redis_queue_test.go b/backend/internal/model/job/repository/redis_queue_test.go similarity index 100% rename from internal/model/job/repository/redis_queue_test.go rename to backend/internal/model/job/repository/redis_queue_test.go diff --git a/internal/model/job/resume/resume.go b/backend/internal/model/job/resume/resume.go similarity index 100% rename from internal/model/job/resume/resume.go rename to backend/internal/model/job/resume/resume.go diff --git a/internal/model/job/resume/resume_test.go b/backend/internal/model/job/resume/resume_test.go similarity index 100% rename from internal/model/job/resume/resume_test.go rename to backend/internal/model/job/resume/resume_test.go diff --git a/internal/model/job/usecase/cancel_test.go b/backend/internal/model/job/usecase/cancel_test.go similarity index 100% rename from internal/model/job/usecase/cancel_test.go rename to backend/internal/model/job/usecase/cancel_test.go diff --git a/internal/model/job/usecase/dedupe_test.go b/backend/internal/model/job/usecase/dedupe_test.go similarity index 100% rename from internal/model/job/usecase/dedupe_test.go rename to backend/internal/model/job/usecase/dedupe_test.go diff --git a/internal/model/job/usecase/helpers.go b/backend/internal/model/job/usecase/helpers.go similarity index 100% rename from internal/model/job/usecase/helpers.go rename to backend/internal/model/job/usecase/helpers.go diff --git a/internal/model/job/usecase/maintenance.go b/backend/internal/model/job/usecase/maintenance.go similarity index 100% rename from internal/model/job/usecase/maintenance.go rename to backend/internal/model/job/usecase/maintenance.go diff --git a/internal/model/job/usecase/maintenance_test.go b/backend/internal/model/job/usecase/maintenance_test.go similarity index 100% rename from internal/model/job/usecase/maintenance_test.go rename to backend/internal/model/job/usecase/maintenance_test.go diff --git a/internal/model/job/usecase/refresh_lock_test.go b/backend/internal/model/job/usecase/refresh_lock_test.go similarity index 100% rename from internal/model/job/usecase/refresh_lock_test.go rename to backend/internal/model/job/usecase/refresh_lock_test.go diff --git a/internal/model/job/usecase/retry_resume_test.go b/backend/internal/model/job/usecase/retry_resume_test.go similarity index 100% rename from internal/model/job/usecase/retry_resume_test.go rename to backend/internal/model/job/usecase/retry_resume_test.go diff --git a/internal/model/job/usecase/retry_test.go b/backend/internal/model/job/usecase/retry_test.go similarity index 100% rename from internal/model/job/usecase/retry_test.go rename to backend/internal/model/job/usecase/retry_test.go diff --git a/internal/model/job/usecase/schedule.go b/backend/internal/model/job/usecase/schedule.go similarity index 100% rename from internal/model/job/usecase/schedule.go rename to backend/internal/model/job/usecase/schedule.go diff --git a/internal/model/job/usecase/schedule_payload.go b/backend/internal/model/job/usecase/schedule_payload.go similarity index 100% rename from internal/model/job/usecase/schedule_payload.go rename to backend/internal/model/job/usecase/schedule_payload.go diff --git a/internal/model/job/usecase/schedule_payload_test.go b/backend/internal/model/job/usecase/schedule_payload_test.go similarity index 100% rename from internal/model/job/usecase/schedule_payload_test.go rename to backend/internal/model/job/usecase/schedule_payload_test.go diff --git a/internal/model/job/usecase/schedule_test.go b/backend/internal/model/job/usecase/schedule_test.go similarity index 100% rename from internal/model/job/usecase/schedule_test.go rename to backend/internal/model/job/usecase/schedule_test.go diff --git a/internal/model/job/usecase/step_resume.go b/backend/internal/model/job/usecase/step_resume.go similarity index 100% rename from internal/model/job/usecase/step_resume.go rename to backend/internal/model/job/usecase/step_resume.go diff --git a/internal/model/job/usecase/template.go b/backend/internal/model/job/usecase/template.go similarity index 100% rename from internal/model/job/usecase/template.go rename to backend/internal/model/job/usecase/template.go diff --git a/internal/model/job/usecase/template_test.go b/backend/internal/model/job/usecase/template_test.go similarity index 100% rename from internal/model/job/usecase/template_test.go rename to backend/internal/model/job/usecase/template_test.go diff --git a/internal/model/job/usecase/test_mocks.go b/backend/internal/model/job/usecase/test_mocks.go similarity index 100% rename from internal/model/job/usecase/test_mocks.go rename to backend/internal/model/job/usecase/test_mocks.go diff --git a/internal/model/job/usecase/usecase.go b/backend/internal/model/job/usecase/usecase.go similarity index 91% rename from internal/model/job/usecase/usecase.go rename to backend/internal/model/job/usecase/usecase.go index 8aafe34..847ece2 100644 --- a/internal/model/job/usecase/usecase.go +++ b/backend/internal/model/job/usecase/usecase.go @@ -14,15 +14,17 @@ import ( ) const ( - demoTemplateType = "demo_long_task" - style8DTemplateType = "style-8d" - expandGraphTemplateType = "expand-graph" - placementScanTemplateType = "placement-scan" - scanViralTemplateType = "scan-viral" - analyzeCopyMissionTemplateType = "analyze-copy-mission" - generateCopyMatrixTemplateType = "generate-copy-matrix" - generateCopyDraftTemplateType = "generate-copy-draft" - style8DWorkerType = "node" + demoTemplateType = "demo_long_task" + style8DTemplateType = "style-8d" + expandGraphTemplateType = "expand-graph" + placementScanTemplateType = "placement-scan" + scanViralTemplateType = "scan-viral" + analyzeCopyMissionTemplateType = "analyze-copy-mission" + generateCopyMatrixTemplateType = "generate-copy-matrix" + generateCopyDraftTemplateType = "generate-copy-draft" + refreshThreadsTokenTemplateType = "refresh-threads-token" + publishAnalyticsTemplateType = "publish-analytics" + style8DWorkerType = "node" ) type UseCase = domusecase.UseCase @@ -99,6 +101,16 @@ func (u *jobUseCase) EnsureGenerateCopyDraftTemplate(ctx context.Context) error return err } +func (u *jobUseCase) EnsureRefreshThreadsTokenTemplate(ctx context.Context) error { + _, err := u.templates.Upsert(ctx, refreshThreadsTokenTemplate()) + return err +} + +func (u *jobUseCase) EnsurePublishAnalyticsTemplate(ctx context.Context) error { + _, err := u.templates.Upsert(ctx, publishAnalyticsTemplate()) + return err +} + func analyzeCopyMissionTemplate() *entity.Template { return &entity.Template{ Type: analyzeCopyMissionTemplateType, @@ -177,6 +189,54 @@ func generateCopyDraftTemplate() *entity.Template { } } +func refreshThreadsTokenTemplate() *entity.Template { + return &entity.Template{ + Type: refreshThreadsTokenTemplateType, + Version: 1, + Name: "Refresh Threads Token", + Description: "Refresh Threads API access token before expiry", + Enabled: true, + Repeatable: true, + ConcurrencyPolicy: string(enum.ConcurrencyAllowParallel), + DedupeKeys: []string{"account_id"}, + TimeoutSeconds: 120, + CancelPolicy: entity.CancelPolicy{ + Supported: false, + }, + RetryPolicy: entity.RetryPolicy{ + MaxAttempts: 3, + BackoffSeconds: []int{30, 120, 300}, + }, + Steps: []entity.TemplateStep{ + {ID: "refresh", Name: "Refresh token", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 120, Cancelable: false}, + }, + } +} + +func publishAnalyticsTemplate() *entity.Template { + return &entity.Template{ + Type: publishAnalyticsTemplateType, + Version: 1, + Name: "Publish Analytics Check", + Description: "Check Threads API insights for a published post", + Enabled: true, + Repeatable: true, + ConcurrencyPolicy: string(enum.ConcurrencyAllowParallel), + DedupeKeys: []string{"media_id", "checkpoint"}, + TimeoutSeconds: 120, + CancelPolicy: entity.CancelPolicy{ + Supported: false, + }, + RetryPolicy: entity.RetryPolicy{ + MaxAttempts: 2, + BackoffSeconds: []int{60}, + }, + Steps: []entity.TemplateStep{ + {ID: "check", Name: "Check analytics", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 120, Cancelable: false}, + }, + } +} + func scanViralTemplate() *entity.Template { return &entity.Template{ Type: scanViralTemplateType, diff --git a/internal/model/knowledge_graph/domain/entity/graph.go b/backend/internal/model/knowledge_graph/domain/entity/graph.go similarity index 100% rename from internal/model/knowledge_graph/domain/entity/graph.go rename to backend/internal/model/knowledge_graph/domain/entity/graph.go diff --git a/internal/model/knowledge_graph/domain/repository/repository.go b/backend/internal/model/knowledge_graph/domain/repository/repository.go similarity index 100% rename from internal/model/knowledge_graph/domain/repository/repository.go rename to backend/internal/model/knowledge_graph/domain/repository/repository.go diff --git a/internal/model/knowledge_graph/domain/usecase/usecase.go b/backend/internal/model/knowledge_graph/domain/usecase/usecase.go similarity index 100% rename from internal/model/knowledge_graph/domain/usecase/usecase.go rename to backend/internal/model/knowledge_graph/domain/usecase/usecase.go diff --git a/internal/model/knowledge_graph/repository/mongo.go b/backend/internal/model/knowledge_graph/repository/mongo.go similarity index 100% rename from internal/model/knowledge_graph/repository/mongo.go rename to backend/internal/model/knowledge_graph/repository/mongo.go diff --git a/internal/model/knowledge_graph/usecase/usecase.go b/backend/internal/model/knowledge_graph/usecase/usecase.go similarity index 100% rename from internal/model/knowledge_graph/usecase/usecase.go rename to backend/internal/model/knowledge_graph/usecase/usecase.go diff --git a/internal/model/member/domain/entity/member.go b/backend/internal/model/member/domain/entity/member.go similarity index 100% rename from internal/model/member/domain/entity/member.go rename to backend/internal/model/member/domain/entity/member.go diff --git a/internal/model/member/domain/repository/member.go b/backend/internal/model/member/domain/repository/member.go similarity index 100% rename from internal/model/member/domain/repository/member.go rename to backend/internal/model/member/domain/repository/member.go diff --git a/internal/model/member/domain/usecase/member.go b/backend/internal/model/member/domain/usecase/member.go similarity index 100% rename from internal/model/member/domain/usecase/member.go rename to backend/internal/model/member/domain/usecase/member.go diff --git a/internal/model/member/repository/mongo.go b/backend/internal/model/member/repository/mongo.go similarity index 100% rename from internal/model/member/repository/mongo.go rename to backend/internal/model/member/repository/mongo.go diff --git a/internal/model/member/usecase/usecase.go b/backend/internal/model/member/usecase/usecase.go similarity index 100% rename from internal/model/member/usecase/usecase.go rename to backend/internal/model/member/usecase/usecase.go diff --git a/internal/model/outreach_draft/domain/entity/draft.go b/backend/internal/model/outreach_draft/domain/entity/draft.go similarity index 100% rename from internal/model/outreach_draft/domain/entity/draft.go rename to backend/internal/model/outreach_draft/domain/entity/draft.go diff --git a/internal/model/outreach_draft/domain/repository/repository.go b/backend/internal/model/outreach_draft/domain/repository/repository.go similarity index 100% rename from internal/model/outreach_draft/domain/repository/repository.go rename to backend/internal/model/outreach_draft/domain/repository/repository.go diff --git a/internal/model/outreach_draft/domain/usecase/usecase.go b/backend/internal/model/outreach_draft/domain/usecase/usecase.go similarity index 100% rename from internal/model/outreach_draft/domain/usecase/usecase.go rename to backend/internal/model/outreach_draft/domain/usecase/usecase.go diff --git a/internal/model/outreach_draft/repository/mongo.go b/backend/internal/model/outreach_draft/repository/mongo.go similarity index 100% rename from internal/model/outreach_draft/repository/mongo.go rename to backend/internal/model/outreach_draft/repository/mongo.go diff --git a/internal/model/outreach_draft/usecase/errors.go b/backend/internal/model/outreach_draft/usecase/errors.go similarity index 100% rename from internal/model/outreach_draft/usecase/errors.go rename to backend/internal/model/outreach_draft/usecase/errors.go diff --git a/internal/model/outreach_draft/usecase/usecase.go b/backend/internal/model/outreach_draft/usecase/usecase.go similarity index 100% rename from internal/model/outreach_draft/usecase/usecase.go rename to backend/internal/model/outreach_draft/usecase/usecase.go diff --git a/internal/model/permission/domain/entity/permission.go b/backend/internal/model/permission/domain/entity/permission.go similarity index 100% rename from internal/model/permission/domain/entity/permission.go rename to backend/internal/model/permission/domain/entity/permission.go diff --git a/internal/model/permission/domain/repository/permission.go b/backend/internal/model/permission/domain/repository/permission.go similarity index 100% rename from internal/model/permission/domain/repository/permission.go rename to backend/internal/model/permission/domain/repository/permission.go diff --git a/internal/model/permission/domain/usecase/permission.go b/backend/internal/model/permission/domain/usecase/permission.go similarity index 100% rename from internal/model/permission/domain/usecase/permission.go rename to backend/internal/model/permission/domain/usecase/permission.go diff --git a/internal/model/permission/repository/mongo_permission.go b/backend/internal/model/permission/repository/mongo_permission.go similarity index 100% rename from internal/model/permission/repository/mongo_permission.go rename to backend/internal/model/permission/repository/mongo_permission.go diff --git a/internal/model/permission/repository/mongo_role_permission.go b/backend/internal/model/permission/repository/mongo_role_permission.go similarity index 100% rename from internal/model/permission/repository/mongo_role_permission.go rename to backend/internal/model/permission/repository/mongo_role_permission.go diff --git a/internal/model/permission/repository/object_id.go b/backend/internal/model/permission/repository/object_id.go similarity index 100% rename from internal/model/permission/repository/object_id.go rename to backend/internal/model/permission/repository/object_id.go diff --git a/internal/model/permission/usecase/usecase.go b/backend/internal/model/permission/usecase/usecase.go similarity index 100% rename from internal/model/permission/usecase/usecase.go rename to backend/internal/model/permission/usecase/usecase.go diff --git a/internal/model/permission/usecase/usecase_test.go b/backend/internal/model/permission/usecase/usecase_test.go similarity index 99% rename from internal/model/permission/usecase/usecase_test.go rename to backend/internal/model/permission/usecase/usecase_test.go index ca9e019..67c60cb 100644 --- a/internal/model/permission/usecase/usecase_test.go +++ b/backend/internal/model/permission/usecase/usecase_test.go @@ -11,4 +11,4 @@ func TestMergeHTTPMethods(t *testing.T) { if got != "GET|POST|PATCH" { t.Fatalf("got %q", got) } -} \ No newline at end of file +} diff --git a/internal/model/persona/domain/entity/copy_research_map.go b/backend/internal/model/persona/domain/entity/copy_research_map.go similarity index 100% rename from internal/model/persona/domain/entity/copy_research_map.go rename to backend/internal/model/persona/domain/entity/copy_research_map.go diff --git a/internal/model/persona/domain/entity/persona.go b/backend/internal/model/persona/domain/entity/persona.go similarity index 100% rename from internal/model/persona/domain/entity/persona.go rename to backend/internal/model/persona/domain/entity/persona.go diff --git a/internal/model/persona/domain/repository/repository.go b/backend/internal/model/persona/domain/repository/repository.go similarity index 100% rename from internal/model/persona/domain/repository/repository.go rename to backend/internal/model/persona/domain/repository/repository.go diff --git a/internal/model/persona/domain/usecase/usecase.go b/backend/internal/model/persona/domain/usecase/usecase.go similarity index 100% rename from internal/model/persona/domain/usecase/usecase.go rename to backend/internal/model/persona/domain/usecase/usecase.go diff --git a/internal/model/persona/repository/mongo.go b/backend/internal/model/persona/repository/mongo.go similarity index 100% rename from internal/model/persona/repository/mongo.go rename to backend/internal/model/persona/repository/mongo.go diff --git a/internal/model/persona/usecase/usecase.go b/backend/internal/model/persona/usecase/usecase.go similarity index 100% rename from internal/model/persona/usecase/usecase.go rename to backend/internal/model/persona/usecase/usecase.go diff --git a/internal/model/placement/usecase/settings.go b/backend/internal/model/placement/usecase/settings.go similarity index 88% rename from internal/model/placement/usecase/settings.go rename to backend/internal/model/placement/usecase/settings.go index ad730d7..1e7f423 100644 --- a/internal/model/placement/usecase/settings.go +++ b/backend/internal/model/placement/usecase/settings.go @@ -4,6 +4,7 @@ import ( "context" "strings" + "haixun-backend/internal/library/crypto" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" libkg "haixun-backend/internal/library/knowledge" @@ -47,10 +48,11 @@ type UseCase interface { type placementUseCase struct { settings settingdomain.UseCase + cipher *crypto.Cipher } -func NewUseCase(settings settingdomain.UseCase) UseCase { - return &placementUseCase{settings: settings} +func NewUseCase(settings settingdomain.UseCase, cipher *crypto.Cipher) UseCase { + return &placementUseCase{settings: settings, cipher: cipher} } func (u *placementUseCase) Get(ctx context.Context, tenantID, ownerUID string) (*Settings, error) { @@ -107,15 +109,49 @@ func (u *placementUseCase) load(ctx context.Context, ownerUID string) (storedSet } return defaults, err } - return mergeSettings(defaults, setting.Value), nil + merged := mergeSettings(defaults, setting.Value) + if u.cipher != nil { + if merged.BraveAPIKey, err = u.decryptKey(merged.BraveAPIKey); err != nil { + return defaults, err + } + if merged.ExaAPIKey, err = u.decryptKey(merged.ExaAPIKey); err != nil { + return defaults, err + } + } + return merged, nil +} + +func (u *placementUseCase) decryptKey(value string) (string, error) { + if value == "" { + return "", nil + } + plain, err := u.cipher.Decrypt(value) + if err != nil { + return "", err + } + return strings.TrimSpace(plain), nil } func (u *placementUseCase) save(ctx context.Context, ownerUID string, value storedSettings) error { + payload := value.toMap() + if u.cipher != nil { + for _, key := range []string{"brave_api_key", "exa_api_key"} { + s, ok := payload[key].(string) + if !ok || s == "" { + continue + } + enc, err := u.cipher.Encrypt(s) + if err != nil { + return err + } + payload[key] = enc + } + } _, err := u.settings.Upsert(ctx, settingdomain.UpsertRequest{ Scope: settingScopeUser, ScopeID: ownerUID, Key: keyResearch, - Value: value.toMap(), + Value: payload, }) return err } diff --git a/internal/model/placement_topic/domain/entity/topic.go b/backend/internal/model/placement_topic/domain/entity/topic.go similarity index 100% rename from internal/model/placement_topic/domain/entity/topic.go rename to backend/internal/model/placement_topic/domain/entity/topic.go diff --git a/internal/model/placement_topic/domain/repository/repository.go b/backend/internal/model/placement_topic/domain/repository/repository.go similarity index 100% rename from internal/model/placement_topic/domain/repository/repository.go rename to backend/internal/model/placement_topic/domain/repository/repository.go diff --git a/internal/model/placement_topic/domain/usecase/usecase.go b/backend/internal/model/placement_topic/domain/usecase/usecase.go similarity index 100% rename from internal/model/placement_topic/domain/usecase/usecase.go rename to backend/internal/model/placement_topic/domain/usecase/usecase.go diff --git a/internal/model/placement_topic/repository/mongo.go b/backend/internal/model/placement_topic/repository/mongo.go similarity index 100% rename from internal/model/placement_topic/repository/mongo.go rename to backend/internal/model/placement_topic/repository/mongo.go diff --git a/internal/model/placement_topic/usecase/usecase.go b/backend/internal/model/placement_topic/usecase/usecase.go similarity index 100% rename from internal/model/placement_topic/usecase/usecase.go rename to backend/internal/model/placement_topic/usecase/usecase.go diff --git a/backend/internal/model/publish_analytics/domain/entity/analytics.go b/backend/internal/model/publish_analytics/domain/entity/analytics.go new file mode 100644 index 0000000..c290a0b --- /dev/null +++ b/backend/internal/model/publish_analytics/domain/entity/analytics.go @@ -0,0 +1,29 @@ +package entity + +const CollectionName = "publish_analytics" + +type PublishAnalytics struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + BrandID string `bson:"brand_id,omitempty"` + PersonaID string `bson:"persona_id,omitempty"` + MediaID string `bson:"media_id"` + Permalink string `bson:"permalink,omitempty"` + PublishedAt int64 `bson:"published_at"` + Checkpoint string `bson:"checkpoint"` + LikeCount int `bson:"like_count,omitempty"` + ReplyCount int `bson:"reply_count,omitempty"` + RepostCount int `bson:"repost_count,omitempty"` + QuoteCount int `bson:"quote_count,omitempty"` + ImpressionCount int `bson:"impression_count,omitempty"` + ReachCount int `bson:"reach_count,omitempty"` + CheckedAt int64 `bson:"checked_at"` + CreateAt int64 `bson:"create_at"` +} + +const ( + Checkpoint1h = "+1h" + Checkpoint24h = "+24h" + Checkpoint7d = "+7d" +) diff --git a/backend/internal/model/publish_analytics/domain/repository/repository.go b/backend/internal/model/publish_analytics/domain/repository/repository.go new file mode 100644 index 0000000..dcd2ced --- /dev/null +++ b/backend/internal/model/publish_analytics/domain/repository/repository.go @@ -0,0 +1,15 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/publish_analytics/domain/entity" +) + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Create(ctx context.Context, analytics *entity.PublishAnalytics) error + Get(ctx context.Context, tenantID, ownerUID, analyticsID string) (*entity.PublishAnalytics, error) + ListByMedia(ctx context.Context, tenantID, ownerUID, mediaID string) ([]entity.PublishAnalytics, error) + Upsert(ctx context.Context, analytics *entity.PublishAnalytics) (*entity.PublishAnalytics, error) +} diff --git a/backend/internal/model/publish_analytics/domain/usecase/usecase.go b/backend/internal/model/publish_analytics/domain/usecase/usecase.go new file mode 100644 index 0000000..4a8f3f1 --- /dev/null +++ b/backend/internal/model/publish_analytics/domain/usecase/usecase.go @@ -0,0 +1,24 @@ +package usecase + +import "context" + +type PublishAnalyticsSummary struct { + ID string + MediaID string + Permalink string + PublishedAt int64 + Checkpoint string + LikeCount int + ReplyCount int + RepostCount int + QuoteCount int + ImpressionCount int + ReachCount int + CheckedAt int64 + CreateAt int64 +} + +type UseCase interface { + RecordCheckpoint(ctx context.Context, tenantID, ownerUID string, summary PublishAnalyticsSummary) (*PublishAnalyticsSummary, error) + ListByMedia(ctx context.Context, tenantID, ownerUID, mediaID string) ([]PublishAnalyticsSummary, error) +} diff --git a/backend/internal/model/publish_analytics/repository/mongo.go b/backend/internal/model/publish_analytics/repository/mongo.go new file mode 100644 index 0000000..6a5862d --- /dev/null +++ b/backend/internal/model/publish_analytics/repository/mongo.go @@ -0,0 +1,125 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/publish_analytics/domain/entity" + domrepo "haixun-backend/internal/model/publish_analytics/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "media_id", Value: 1}, + {Key: "checkpoint", Value: 1}, + }, + }, + }) + return err +} + +func actorFilter(tenantID, ownerUID string) bson.M { + return bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + } +} + +func (r *mongoRepository) Create(ctx context.Context, analytics *entity.PublishAnalytics) error { + if r.collection == nil { + return app.For(code.AI).DBUnavailable("Mongo is not configured") + } + if analytics == nil { + return app.For(code.AI).InputMissingRequired("analytics is required") + } + _, err := r.collection.InsertOne(ctx, analytics) + return err +} + +func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, analyticsID string) (*entity.PublishAnalytics, error) { + if r.collection == nil { + return nil, app.For(code.AI).DBUnavailable("Mongo is not configured") + } + analyticsID = strings.TrimSpace(analyticsID) + if analyticsID == "" { + return nil, app.For(code.AI).InputMissingRequired("analytics_id is required") + } + filter := actorFilter(tenantID, ownerUID) + filter["_id"] = analyticsID + var out entity.PublishAnalytics + err := r.collection.FindOne(ctx, filter).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.AI).ResNotFound("publish analytics not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) ListByMedia(ctx context.Context, tenantID, ownerUID, mediaID string) ([]entity.PublishAnalytics, error) { + if r.collection == nil { + return nil, app.For(code.AI).DBUnavailable("Mongo is not configured") + } + mediaID = strings.TrimSpace(mediaID) + if mediaID == "" { + return nil, app.For(code.AI).InputMissingRequired("media_id is required") + } + filter := actorFilter(tenantID, ownerUID) + filter["media_id"] = mediaID + opts := options.Find().SetSort(bson.D{{Key: "checked_at", Value: -1}}) + cur, err := r.collection.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.PublishAnalytics + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +func (r *mongoRepository) Upsert(ctx context.Context, analytics *entity.PublishAnalytics) (*entity.PublishAnalytics, error) { + if r.collection == nil { + return nil, app.For(code.AI).DBUnavailable("Mongo is not configured") + } + if analytics == nil { + return nil, app.For(code.AI).InputMissingRequired("analytics is required") + } + if analytics.ID == "" { + return nil, app.For(code.AI).InputMissingRequired("analytics id is required") + } + + filter := bson.M{"_id": analytics.ID} + opts := options.Replace().SetUpsert(true) + _, err := r.collection.ReplaceOne(ctx, filter, analytics, opts) + if err != nil { + return nil, err + } + return analytics, nil +} diff --git a/backend/internal/model/publish_analytics/usecase/usecase.go b/backend/internal/model/publish_analytics/usecase/usecase.go new file mode 100644 index 0000000..fe8d3b3 --- /dev/null +++ b/backend/internal/model/publish_analytics/usecase/usecase.go @@ -0,0 +1,105 @@ +package usecase + +import ( + "context" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/publish_analytics/domain/entity" + domrepo "haixun-backend/internal/model/publish_analytics/domain/repository" + domusecase "haixun-backend/internal/model/publish_analytics/domain/usecase" + + "github.com/google/uuid" +) + +type publishAnalyticsUseCase struct { + repo domrepo.Repository +} + +func NewUseCase(repo domrepo.Repository) domusecase.UseCase { + return &publishAnalyticsUseCase{repo: repo} +} + +func (u *publishAnalyticsUseCase) RecordCheckpoint(ctx context.Context, tenantID, ownerUID string, summary domusecase.PublishAnalyticsSummary) (*domusecase.PublishAnalyticsSummary, error) { + tenantID = strings.TrimSpace(tenantID) + ownerUID = strings.TrimSpace(ownerUID) + if tenantID == "" || ownerUID == "" { + return nil, app.For(code.AI).AuthUnauthorized("missing actor") + } + mediaID := strings.TrimSpace(summary.MediaID) + if mediaID == "" { + return nil, app.For(code.AI).InputMissingRequired("media_id is required") + } + checkpoint := strings.TrimSpace(summary.Checkpoint) + if checkpoint == "" { + return nil, app.For(code.AI).InputMissingRequired("checkpoint is required") + } + + now := clock.NowUnixNano() + analytics := &entity.PublishAnalytics{ + ID: uuid.NewString(), + TenantID: tenantID, + OwnerUID: ownerUID, + MediaID: mediaID, + Permalink: strings.TrimSpace(summary.Permalink), + PublishedAt: summary.PublishedAt, + Checkpoint: checkpoint, + LikeCount: summary.LikeCount, + ReplyCount: summary.ReplyCount, + RepostCount: summary.RepostCount, + QuoteCount: summary.QuoteCount, + ImpressionCount: summary.ImpressionCount, + ReachCount: summary.ReachCount, + CheckedAt: now, + CreateAt: now, + } + + created, err := u.repo.Upsert(ctx, analytics) + if err != nil { + return nil, err + } + out := toSummary(*created) + return &out, nil +} + +func (u *publishAnalyticsUseCase) ListByMedia(ctx context.Context, tenantID, ownerUID, mediaID string) ([]domusecase.PublishAnalyticsSummary, error) { + tenantID = strings.TrimSpace(tenantID) + ownerUID = strings.TrimSpace(ownerUID) + mediaID = strings.TrimSpace(mediaID) + if tenantID == "" || ownerUID == "" { + return nil, app.For(code.AI).AuthUnauthorized("missing actor") + } + if mediaID == "" { + return nil, app.For(code.AI).InputMissingRequired("media_id is required") + } + + items, err := u.repo.ListByMedia(ctx, tenantID, ownerUID, mediaID) + if err != nil { + return nil, err + } + out := make([]domusecase.PublishAnalyticsSummary, 0, len(items)) + for _, item := range items { + out = append(out, toSummary(item)) + } + return out, nil +} + +func toSummary(item entity.PublishAnalytics) domusecase.PublishAnalyticsSummary { + return domusecase.PublishAnalyticsSummary{ + ID: item.ID, + MediaID: item.MediaID, + Permalink: item.Permalink, + PublishedAt: item.PublishedAt, + Checkpoint: item.Checkpoint, + LikeCount: item.LikeCount, + ReplyCount: item.ReplyCount, + RepostCount: item.RepostCount, + QuoteCount: item.QuoteCount, + ImpressionCount: item.ImpressionCount, + ReachCount: item.ReachCount, + CheckedAt: item.CheckedAt, + CreateAt: item.CreateAt, + } +} diff --git a/internal/model/scan_post/domain/entity/outreach_patch.go b/backend/internal/model/scan_post/domain/entity/outreach_patch.go similarity index 100% rename from internal/model/scan_post/domain/entity/outreach_patch.go rename to backend/internal/model/scan_post/domain/entity/outreach_patch.go diff --git a/backend/internal/model/scan_post/domain/entity/post.go b/backend/internal/model/scan_post/domain/entity/post.go new file mode 100644 index 0000000..1f0921f --- /dev/null +++ b/backend/internal/model/scan_post/domain/entity/post.go @@ -0,0 +1,67 @@ +package entity + +const CollectionName = "scan_posts" + +const FlowViral = "viral" +const FlowPlacement = "placement" + +type ScanReply struct { + ExternalID string `bson:"external_id,omitempty"` + Author string `bson:"author,omitempty"` + Text string `bson:"text"` + Permalink string `bson:"permalink,omitempty"` + LikeCount int `bson:"like_count,omitempty"` + PostedAt string `bson:"posted_at,omitempty"` +} + +type ScanPost struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + BrandID string `bson:"brand_id"` + TopicID string `bson:"topic_id,omitempty"` + LegacyPersonaID string `bson:"persona_id,omitempty"` + CopyMissionID string `bson:"copy_mission_id,omitempty"` + Flow string `bson:"flow,omitempty"` + GraphID string `bson:"graph_id"` + ScanJobID string `bson:"scan_job_id"` + GraphNodeID string `bson:"graph_node_id"` + SearchTag string `bson:"search_tag"` + QueryDimension string `bson:"query_dimension"` + ExternalID string `bson:"external_id"` + Permalink string `bson:"permalink"` + Author string `bson:"author"` + AuthorID string `bson:"author_id,omitempty"` + AuthorAvatar string `bson:"author_avatar,omitempty"` + AuthorVerified bool `bson:"author_verified,omitempty"` + FollowerCount int `bson:"follower_count,omitempty"` + AuthorFollowers int `bson:"author_followers,omitempty"` + Text string `bson:"text"` + Priority string `bson:"priority"` + LikeCount int `bson:"like_count,omitempty"` + ReplyCount int `bson:"reply_count,omitempty"` + EngagementScore int `bson:"engagement_score,omitempty"` + PlacementScore int `bson:"placement_score"` + ProductFitScore int `bson:"product_fit_score"` + SolvedByProduct bool `bson:"solved_by_product"` + Source string `bson:"source"` + OutreachStatus string `bson:"outreach_status,omitempty"` + PublishedReplyID string `bson:"published_reply_id,omitempty"` + PublishedPermalink string `bson:"published_permalink,omitempty"` + OutreachUpdateAt int64 `bson:"outreach_update_at,omitempty"` + PostedAt string `bson:"posted_at,omitempty"` + Replies []ScanReply `bson:"replies,omitempty"` + Embedding []float32 `bson:"embedding,omitempty"` + SemanticScore int `bson:"semantic_score,omitempty"` + EngagementPredicted int `bson:"engagement_predicted,omitempty"` + AudienceQualityScore int `bson:"audience_quality_score,omitempty"` + CreateAt int64 `bson:"create_at"` +} + +const ( + OutreachStatusPending = "pending" + OutreachStatusDrafted = "drafted" + OutreachStatusPublished = "published" + OutreachStatusHandled = "handled" + OutreachStatusSkipped = "skipped" +) diff --git a/internal/model/scan_post/domain/repository/repository.go b/backend/internal/model/scan_post/domain/repository/repository.go similarity index 99% rename from internal/model/scan_post/domain/repository/repository.go rename to backend/internal/model/scan_post/domain/repository/repository.go index 5940b62..8b9ac28 100644 --- a/internal/model/scan_post/domain/repository/repository.go +++ b/backend/internal/model/scan_post/domain/repository/repository.go @@ -11,6 +11,7 @@ type ListFilter struct { TopicID string Priority string ProductFitMin int + SemanticMin int Recent7dOnly bool Limit int } diff --git a/internal/model/scan_post/domain/usecase/usecase.go b/backend/internal/model/scan_post/domain/usecase/usecase.go similarity index 70% rename from internal/model/scan_post/domain/usecase/usecase.go rename to backend/internal/model/scan_post/domain/usecase/usecase.go index 57186b8..0e0ab16 100644 --- a/internal/model/scan_post/domain/usecase/usecase.go +++ b/backend/internal/model/scan_post/domain/usecase/usecase.go @@ -16,36 +16,43 @@ type ScanReplySummary struct { } type ScanPostSummary struct { - ID string - BrandID string - PersonaID string - CopyMissionID string - Flow string - GraphNodeID string - SearchTag string - QueryDimension string - ExternalID string - Permalink string - Author string - AuthorVerified bool - FollowerCount int - Text string - Priority string - LikeCount int - ReplyCount int - EngagementScore int - PlacementScore int - ProductFitScore int - SolvedByProduct bool - Source string - ScanJobID string - OutreachStatus string - PublishedReplyID string - PublishedPermalink string - OutreachUpdateAt int64 - PostedAt string - Replies []ScanReplySummary - CreateAt int64 + ID string + BrandID string + PersonaID string + CopyMissionID string + Flow string + GraphNodeID string + SearchTag string + QueryDimension string + ExternalID string + Permalink string + Author string + AuthorID string + AuthorAvatar string + AuthorVerified bool + FollowerCount int + AuthorFollowers int + Text string + Priority string + LikeCount int + ReplyCount int + EngagementScore int + PlacementScore int + ProductFitScore int + SolvedByProduct bool + Source string + ScanJobID string + OutreachStatus string + PublishedReplyID string + PublishedPermalink string + OutreachUpdateAt int64 + PostedAt string + Replies []ScanReplySummary + Embedding []float32 + SemanticScore int + EngagementPredicted int + AudienceQualityScore int + CreateAt int64 } type ListRequest struct { @@ -55,6 +62,7 @@ type ListRequest struct { TopicID string Priority string ProductFitMin int + SemanticMin int Recent7dOnly bool Limit int } diff --git a/internal/model/scan_post/repository/mongo.go b/backend/internal/model/scan_post/repository/mongo.go similarity index 99% rename from internal/model/scan_post/repository/mongo.go rename to backend/internal/model/scan_post/repository/mongo.go index 0531d31..5c955ff 100644 --- a/internal/model/scan_post/repository/mongo.go +++ b/backend/internal/model/scan_post/repository/mongo.go @@ -447,6 +447,9 @@ func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, f if filter.ProductFitMin > 0 { query["product_fit_score"] = bson.M{"$gte": filter.ProductFitMin} } + if filter.SemanticMin > 0 { + query["semantic_score"] = bson.M{"$gte": filter.SemanticMin} + } limit := filter.Limit if limit <= 0 { limit = 100 diff --git a/internal/model/scan_post/repository/mongo_priority_filter_test.go b/backend/internal/model/scan_post/repository/mongo_priority_filter_test.go similarity index 100% rename from internal/model/scan_post/repository/mongo_priority_filter_test.go rename to backend/internal/model/scan_post/repository/mongo_priority_filter_test.go diff --git a/internal/model/scan_post/repository/mongo_topic_scope_test.go b/backend/internal/model/scan_post/repository/mongo_topic_scope_test.go similarity index 100% rename from internal/model/scan_post/repository/mongo_topic_scope_test.go rename to backend/internal/model/scan_post/repository/mongo_topic_scope_test.go diff --git a/internal/model/scan_post/usecase/errors.go b/backend/internal/model/scan_post/usecase/errors.go similarity index 100% rename from internal/model/scan_post/usecase/errors.go rename to backend/internal/model/scan_post/usecase/errors.go diff --git a/internal/model/scan_post/usecase/usecase.go b/backend/internal/model/scan_post/usecase/usecase.go similarity index 70% rename from internal/model/scan_post/usecase/usecase.go rename to backend/internal/model/scan_post/usecase/usecase.go index 0669f88..05988f8 100644 --- a/internal/model/scan_post/usecase/usecase.go +++ b/backend/internal/model/scan_post/usecase/usecase.go @@ -43,28 +43,35 @@ func (u *scanPostUseCase) ReplaceFromViralScan(ctx context.Context, req domuseca entities := make([]entity.ScanPost, 0, len(req.Posts)) for _, item := range req.Posts { entities = append(entities, entity.ScanPost{ - ID: uuid.NewString(), - TenantID: req.TenantID, - OwnerUID: req.OwnerUID, - LegacyPersonaID: req.PersonaID, - CopyMissionID: strings.TrimSpace(req.CopyMissionID), - Flow: entity.FlowViral, - ScanJobID: req.ScanJobID, - SearchTag: item.SearchTag, - ExternalID: item.ExternalID, - Permalink: item.Permalink, - Author: item.Author, - AuthorVerified: item.AuthorVerified, - FollowerCount: item.FollowerCount, - Text: item.Text, - Priority: item.Priority, - LikeCount: item.LikeCount, - ReplyCount: item.ReplyCount, - EngagementScore: item.EngagementScore, - PlacementScore: item.PlacementScore, - Source: string(item.Source), - Replies: toReplyEntities(item.Replies), - CreateAt: now, + ID: uuid.NewString(), + TenantID: req.TenantID, + OwnerUID: req.OwnerUID, + LegacyPersonaID: req.PersonaID, + CopyMissionID: strings.TrimSpace(req.CopyMissionID), + Flow: entity.FlowViral, + ScanJobID: req.ScanJobID, + SearchTag: item.SearchTag, + ExternalID: item.ExternalID, + Permalink: item.Permalink, + Author: item.Author, + AuthorID: item.AuthorID, + AuthorAvatar: item.AuthorAvatar, + AuthorVerified: item.AuthorVerified, + FollowerCount: item.FollowerCount, + AuthorFollowers: item.AuthorFollowers, + Text: item.Text, + Priority: item.Priority, + LikeCount: item.LikeCount, + ReplyCount: item.ReplyCount, + EngagementScore: item.EngagementScore, + PlacementScore: item.PlacementScore, + Embedding: item.Embedding, + SemanticScore: item.SemanticScore, + EngagementPredicted: item.EngagementPredicted, + AudienceQualityScore: item.AudienceQualityScore, + Source: string(item.Source), + Replies: toReplyEntities(item.Replies), + CreateAt: now, }) } if err := u.repo.ReplaceForViralScan(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.CopyMissionID, req.ScanJobID, entities); err != nil { @@ -128,30 +135,37 @@ func placementCandidatesToEntities(tenantID, ownerUID, brandID, topicID, graphID continue } entities = append(entities, entity.ScanPost{ - ID: uuid.NewString(), - TenantID: tenantID, - OwnerUID: ownerUID, - BrandID: brandID, - TopicID: strings.TrimSpace(topicID), - Flow: entity.FlowPlacement, - GraphID: graphID, - ScanJobID: scanJobID, - GraphNodeID: item.GraphNodeID, - SearchTag: item.SearchTag, - QueryDimension: string(item.QueryDimension), - ExternalID: item.ExternalID, - Permalink: item.Permalink, - Author: item.Author, - Text: item.Text, - Priority: item.Priority, - PlacementScore: item.PlacementScore, - ProductFitScore: item.ProductFitScore, - SolvedByProduct: item.SolvedByProduct, - Source: string(item.Source), - OutreachStatus: entity.OutreachStatusPending, - PostedAt: strings.TrimSpace(item.PostedAt), - Replies: toReplyEntities(item.Replies), - CreateAt: now, + ID: uuid.NewString(), + TenantID: tenantID, + OwnerUID: ownerUID, + BrandID: brandID, + TopicID: strings.TrimSpace(topicID), + Flow: entity.FlowPlacement, + GraphID: graphID, + ScanJobID: scanJobID, + GraphNodeID: item.GraphNodeID, + SearchTag: item.SearchTag, + QueryDimension: string(item.QueryDimension), + ExternalID: item.ExternalID, + Permalink: item.Permalink, + Author: item.Author, + AuthorID: item.AuthorID, + AuthorAvatar: item.AuthorAvatar, + AuthorFollowers: item.AuthorFollowers, + Text: item.Text, + Priority: item.Priority, + PlacementScore: item.PlacementScore, + ProductFitScore: item.ProductFitScore, + SolvedByProduct: item.SolvedByProduct, + Source: string(item.Source), + OutreachStatus: entity.OutreachStatusPending, + PostedAt: strings.TrimSpace(item.PostedAt), + Replies: toReplyEntities(item.Replies), + Embedding: item.Embedding, + SemanticScore: item.SemanticScore, + EngagementPredicted: item.EngagementPredicted, + AudienceQualityScore: item.AudienceQualityScore, + CreateAt: now, }) } return entities @@ -276,6 +290,7 @@ func (u *scanPostUseCase) List(ctx context.Context, req domusecase.ListRequest) TopicID: req.TopicID, Priority: req.Priority, ProductFitMin: req.ProductFitMin, + SemanticMin: req.SemanticMin, Recent7dOnly: req.Recent7dOnly, Limit: req.Limit, }) @@ -361,35 +376,42 @@ func toReplySummaries(replies []entity.ScanReply) []domusecase.ScanReplySummary func toSummary(item entity.ScanPost) domusecase.ScanPostSummary { return domusecase.ScanPostSummary{ - ID: item.ID, - BrandID: libmongo.ResolveBrandID(item.BrandID, item.LegacyPersonaID), - PersonaID: strings.TrimSpace(item.LegacyPersonaID), - CopyMissionID: item.CopyMissionID, - Flow: item.Flow, - GraphNodeID: item.GraphNodeID, - SearchTag: item.SearchTag, - QueryDimension: item.QueryDimension, - ExternalID: item.ExternalID, - Permalink: item.Permalink, - Author: item.Author, - AuthorVerified: item.AuthorVerified, - FollowerCount: item.FollowerCount, - Text: item.Text, - Priority: item.Priority, - LikeCount: item.LikeCount, - ReplyCount: item.ReplyCount, - EngagementScore: item.EngagementScore, - PlacementScore: item.PlacementScore, - ProductFitScore: item.ProductFitScore, - SolvedByProduct: item.SolvedByProduct, - Source: item.Source, - ScanJobID: item.ScanJobID, - OutreachStatus: item.OutreachStatus, - PublishedReplyID: item.PublishedReplyID, - PublishedPermalink: item.PublishedPermalink, - OutreachUpdateAt: item.OutreachUpdateAt, - PostedAt: item.PostedAt, - Replies: toReplySummaries(item.Replies), - CreateAt: item.CreateAt, + ID: item.ID, + BrandID: libmongo.ResolveBrandID(item.BrandID, item.LegacyPersonaID), + PersonaID: strings.TrimSpace(item.LegacyPersonaID), + CopyMissionID: item.CopyMissionID, + Flow: item.Flow, + GraphNodeID: item.GraphNodeID, + SearchTag: item.SearchTag, + QueryDimension: item.QueryDimension, + ExternalID: item.ExternalID, + Permalink: item.Permalink, + Author: item.Author, + AuthorID: item.AuthorID, + AuthorAvatar: item.AuthorAvatar, + AuthorVerified: item.AuthorVerified, + FollowerCount: item.FollowerCount, + AuthorFollowers: item.AuthorFollowers, + Text: item.Text, + Priority: item.Priority, + LikeCount: item.LikeCount, + ReplyCount: item.ReplyCount, + EngagementScore: item.EngagementScore, + PlacementScore: item.PlacementScore, + ProductFitScore: item.ProductFitScore, + SolvedByProduct: item.SolvedByProduct, + Source: item.Source, + ScanJobID: item.ScanJobID, + OutreachStatus: item.OutreachStatus, + PublishedReplyID: item.PublishedReplyID, + PublishedPermalink: item.PublishedPermalink, + OutreachUpdateAt: item.OutreachUpdateAt, + PostedAt: item.PostedAt, + Replies: toReplySummaries(item.Replies), + Embedding: item.Embedding, + SemanticScore: item.SemanticScore, + EngagementPredicted: item.EngagementPredicted, + AudienceQualityScore: item.AudienceQualityScore, + CreateAt: item.CreateAt, } } diff --git a/internal/model/setting/domain/entity/setting.go b/backend/internal/model/setting/domain/entity/setting.go similarity index 100% rename from internal/model/setting/domain/entity/setting.go rename to backend/internal/model/setting/domain/entity/setting.go diff --git a/internal/model/setting/domain/repository/setting.go b/backend/internal/model/setting/domain/repository/setting.go similarity index 100% rename from internal/model/setting/domain/repository/setting.go rename to backend/internal/model/setting/domain/repository/setting.go diff --git a/internal/model/setting/domain/usecase/setting.go b/backend/internal/model/setting/domain/usecase/setting.go similarity index 100% rename from internal/model/setting/domain/usecase/setting.go rename to backend/internal/model/setting/domain/usecase/setting.go diff --git a/internal/model/setting/repository/mongo.go b/backend/internal/model/setting/repository/mongo.go similarity index 100% rename from internal/model/setting/repository/mongo.go rename to backend/internal/model/setting/repository/mongo.go diff --git a/internal/model/setting/usecase/usecase.go b/backend/internal/model/setting/usecase/usecase.go similarity index 100% rename from internal/model/setting/usecase/usecase.go rename to backend/internal/model/setting/usecase/usecase.go diff --git a/internal/model/threads_account/domain/entity/account.go b/backend/internal/model/threads_account/domain/entity/account.go similarity index 100% rename from internal/model/threads_account/domain/entity/account.go rename to backend/internal/model/threads_account/domain/entity/account.go diff --git a/internal/model/threads_account/domain/entity/secrets.go b/backend/internal/model/threads_account/domain/entity/secrets.go similarity index 100% rename from internal/model/threads_account/domain/entity/secrets.go rename to backend/internal/model/threads_account/domain/entity/secrets.go diff --git a/internal/model/threads_account/domain/repository/repository.go b/backend/internal/model/threads_account/domain/repository/repository.go similarity index 88% rename from internal/model/threads_account/domain/repository/repository.go rename to backend/internal/model/threads_account/domain/repository/repository.go index 3c31e1e..3cafd6b 100644 --- a/internal/model/threads_account/domain/repository/repository.go +++ b/backend/internal/model/threads_account/domain/repository/repository.go @@ -19,4 +19,5 @@ type SecretsRepository interface { EnsureIndexes(ctx context.Context) error FindByAccountID(ctx context.Context, accountID string) (*entity.Secrets, error) SaveBrowserStorageState(ctx context.Context, accountID, storageState string) (*entity.Secrets, error) + SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*entity.Secrets, error) } diff --git a/internal/model/threads_account/domain/usecase/usecase.go b/backend/internal/model/threads_account/domain/usecase/usecase.go similarity index 91% rename from internal/model/threads_account/domain/usecase/usecase.go rename to backend/internal/model/threads_account/domain/usecase/usecase.go index 184378a..df9f5c7 100644 --- a/internal/model/threads_account/domain/usecase/usecase.go +++ b/backend/internal/model/threads_account/domain/usecase/usecase.go @@ -77,6 +77,13 @@ type ImportBrowserSessionResult struct { UpdateAt int64 } +type SecretsData struct { + AccountID string + APIAccessToken string + APITokenExpiresAt int64 + UpdateAt int64 +} + type BrowserSessionData struct { AccountID string StorageState string @@ -142,8 +149,10 @@ type UseCase interface { GetBrowserSession(ctx context.Context, tenantID, ownerUID, accountID string) (*BrowserSessionData, error) GetAiSettings(ctx context.Context, tenantID, ownerUID, accountID string) (*AiSettings, error) UpdateAiSettings(ctx context.Context, tenantID, ownerUID, accountID string, patch AiSettingsPatch) (*AiSettings, error) + ResolveAiProviderAPIKey(ctx context.Context, tenantID, ownerUID, accountID, provider, overrideKey string) (string, error) ResolveWorkerAiCredential(ctx context.Context, tenantID, ownerUID, accountID string) (*WorkerAiCredential, error) ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error) ResolveMemberPlacementContext(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberContext, error) ResolveMemberThreadsPublishCredential(ctx context.Context, tenantID, ownerUID string) (*ThreadsPublishCredential, error) + SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*SecretsData, error) } diff --git a/internal/model/threads_account/repository/mongo.go b/backend/internal/model/threads_account/repository/mongo.go similarity index 100% rename from internal/model/threads_account/repository/mongo.go rename to backend/internal/model/threads_account/repository/mongo.go diff --git a/internal/model/threads_account/repository/secrets_mongo.go b/backend/internal/model/threads_account/repository/secrets_mongo.go similarity index 54% rename from internal/model/threads_account/repository/secrets_mongo.go rename to backend/internal/model/threads_account/repository/secrets_mongo.go index 3054eef..11fb167 100644 --- a/internal/model/threads_account/repository/secrets_mongo.go +++ b/backend/internal/model/threads_account/repository/secrets_mongo.go @@ -4,6 +4,7 @@ import ( "context" "haixun-backend/internal/library/clock" + "haixun-backend/internal/library/crypto" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" "haixun-backend/internal/model/threads_account/domain/entity" @@ -16,13 +17,14 @@ import ( type secretsMongoRepository struct { collection *mongo.Collection + cipher *crypto.Cipher } -func NewSecretsMongoRepository(db *mongo.Database) domrepo.SecretsRepository { +func NewSecretsMongoRepository(db *mongo.Database, cipher *crypto.Cipher) domrepo.SecretsRepository { if db == nil { - return &secretsMongoRepository{} + return &secretsMongoRepository{cipher: cipher} } - return &secretsMongoRepository{collection: db.Collection(entity.SecretsCollectionName)} + return &secretsMongoRepository{collection: db.Collection(entity.SecretsCollectionName), cipher: cipher} } func (r *secretsMongoRepository) EnsureIndexes(ctx context.Context) error { @@ -47,10 +49,51 @@ func (r *secretsMongoRepository) FindByAccountID(ctx context.Context, accountID if err != nil { return nil, err } + if r.cipher != nil && out.BrowserStorageState != "" { + plain, decErr := r.cipher.Decrypt(out.BrowserStorageState) + if decErr != nil { + return nil, decErr + } + out.BrowserStorageState = plain + } return &out, nil } func (r *secretsMongoRepository) SaveBrowserStorageState(ctx context.Context, accountID, storageState string) (*entity.Secrets, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + now := clock.NowUnixNano() + storedValue := storageState + if r.cipher != nil { + enc, encErr := r.cipher.Encrypt(storageState) + if encErr != nil { + return nil, encErr + } + storedValue = enc + } + var out entity.Secrets + err := r.collection.FindOneAndUpdate( + ctx, + bson.M{"_id": accountID}, + bson.M{ + "$set": bson.M{ + "browser_storage_state": storedValue, + "update_at": now, + }, + "$setOnInsert": bson.M{"_id": accountID}, + }, + options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After), + ).Decode(&out) + if err != nil { + return nil, err + } + // Return plaintext to callers regardless of at-rest encryption. + out.BrowserStorageState = storageState + return &out, nil +} + +func (r *secretsMongoRepository) SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*entity.Secrets, error) { if r.collection == nil { return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") } @@ -61,8 +104,9 @@ func (r *secretsMongoRepository) SaveBrowserStorageState(ctx context.Context, ac bson.M{"_id": accountID}, bson.M{ "$set": bson.M{ - "browser_storage_state": storageState, - "update_at": now, + "api_access_token": accessToken, + "api_token_expires_at": expiresAt, + "update_at": now, }, "$setOnInsert": bson.M{"_id": accountID}, }, @@ -71,5 +115,7 @@ func (r *secretsMongoRepository) SaveBrowserStorageState(ctx context.Context, ac if err != nil { return nil, err } + out.APIAccessToken = accessToken + out.APITokenExpiresAt = expiresAt return &out, nil } diff --git a/internal/model/threads_account/usecase/ai_credentials.go b/backend/internal/model/threads_account/usecase/ai_credentials.go similarity index 79% rename from internal/model/threads_account/usecase/ai_credentials.go rename to backend/internal/model/threads_account/usecase/ai_credentials.go index 9016317..6b1e1ea 100644 --- a/internal/model/threads_account/usecase/ai_credentials.go +++ b/backend/internal/model/threads_account/usecase/ai_credentials.go @@ -87,6 +87,29 @@ func (u *threadsAccountUseCase) ResolveWorkerAiCredential( }, nil } +func (u *threadsAccountUseCase) ResolveAiProviderAPIKey( + ctx context.Context, + tenantID, ownerUID, accountID, provider, overrideKey string, +) (string, error) { + account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID) + if err != nil { + return "", err + } + trimmedOverride := strings.TrimSpace(overrideKey) + if trimmedOverride != "" && !isMaskedAPIKey(trimmedOverride) { + return trimmedOverride, nil + } + stored, err := u.loadAiCredentials(ctx, ownerUID, account.ID) + if err != nil { + return "", err + } + apiKey := strings.TrimSpace(stored.ApiKeys[provider]) + if apiKey == "" { + return "", app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 " + provider + " API key") + } + return apiKey, nil +} + func (u *threadsAccountUseCase) UpdateAiSettings( ctx context.Context, tenantID, ownerUID, accountID string, @@ -129,7 +152,7 @@ func (u *threadsAccountUseCase) loadAiCredentials(ctx context.Context, ownerUID, defaults := defaultAiCredentials() setting, err := u.settings.Get(ctx, settingScopeUser, ownerUID, keyAiCredentials) if err == nil { - return mergeAiCredentials(defaults, setting.Value), nil + return u.mergeAiCredentials(defaults, setting.Value) } if !isSettingNotFound(err) { return defaults, err @@ -142,7 +165,10 @@ func (u *threadsAccountUseCase) loadAiCredentials(ctx context.Context, ownerUID, } return defaults, err } - creds := mergeAiCredentials(defaults, legacy.Value) + creds, err := u.mergeAiCredentials(defaults, legacy.Value) + if err != nil { + return defaults, err + } if saveErr := u.saveAiCredentials(ctx, ownerUID, creds); saveErr != nil { return creds, saveErr } @@ -150,18 +176,24 @@ func (u *threadsAccountUseCase) loadAiCredentials(ctx context.Context, ownerUID, } func (u *threadsAccountUseCase) saveAiCredentials(ctx context.Context, ownerUID string, creds aiCredentials) error { - _, err := u.settings.Upsert(ctx, settingdomain.UpsertRequest{ + value, err := u.aiCredentialsToMap(creds) + if err != nil { + return err + } + _, err = u.settings.Upsert(ctx, settingdomain.UpsertRequest{ Scope: settingScopeUser, ScopeID: ownerUID, Key: keyAiCredentials, - Value: aiCredentialsToMap(creds), + Value: value, }) return err } -func mergeAiCredentials(defaults aiCredentials, value map[string]interface{}) aiCredentials { +// mergeAiCredentials merges stored setting values onto defaults, decrypting any +// at-rest encrypted API keys. +func (u *threadsAccountUseCase) mergeAiCredentials(defaults aiCredentials, value map[string]interface{}) (aiCredentials, error) { if value == nil { - return defaults + return defaults, nil } if v, ok := value["provider"].(string); ok && strings.TrimSpace(v) != "" { defaults.Provider = v @@ -178,13 +210,23 @@ func mergeAiCredentials(defaults aiCredentials, value map[string]interface{}) ai if raw, ok := value["api_keys"].(map[string]interface{}); ok { keys := map[string]string{} for provider, item := range raw { - if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { - keys[provider] = strings.TrimSpace(s) + s, ok := item.(string) + if !ok || strings.TrimSpace(s) == "" { + continue } + plain := strings.TrimSpace(s) + if u.cipher != nil { + decrypted, err := u.cipher.Decrypt(plain) + if err != nil { + return defaults, err + } + plain = strings.TrimSpace(decrypted) + } + keys[provider] = plain } defaults.ApiKeys = keys } - return defaults + return defaults, nil } func applyAiSettingsPatch(current aiCredentials, patch domusecase.AiSettingsPatch) aiCredentials { @@ -215,10 +257,18 @@ func applyAiSettingsPatch(current aiCredentials, patch domusecase.AiSettingsPatc return current } -func aiCredentialsToMap(creds aiCredentials) map[string]interface{} { +func (u *threadsAccountUseCase) aiCredentialsToMap(creds aiCredentials) (map[string]interface{}, error) { keys := map[string]interface{}{} for provider, value := range creds.ApiKeys { - keys[provider] = value + stored := value + if u.cipher != nil { + enc, err := u.cipher.Encrypt(value) + if err != nil { + return nil, err + } + stored = enc + } + keys[provider] = stored } return map[string]interface{}{ "provider": creds.Provider, @@ -226,7 +276,7 @@ func aiCredentialsToMap(creds aiCredentials) map[string]interface{} { "research_provider": creds.ResearchProvider, "research_model": creds.ResearchModel, "api_keys": keys, - } + }, nil } func toPublicAiSettings(accountID string, creds aiCredentials) *domusecase.AiSettings { diff --git a/backend/internal/model/threads_account/usecase/connection_prefs_test.go b/backend/internal/model/threads_account/usecase/connection_prefs_test.go new file mode 100644 index 0000000..073aafe --- /dev/null +++ b/backend/internal/model/threads_account/usecase/connection_prefs_test.go @@ -0,0 +1,47 @@ +package usecase + +import ( + "testing" + + domusecase "haixun-backend/internal/model/threads_account/domain/usecase" +) + +func TestApplyConnectionPatchSearchSourceInDevMode(t *testing.T) { + current := deriveConnectionPrefsFromDevMode(true) + patch := domusecase.ConnectionPrefsPatch{ + SearchSourceMode: strPtr("brave"), + } + next := applyConnectionPatch(current, patch) + if next.SearchSourceMode != "brave" { + t.Fatalf("expected brave, got %s", next.SearchSourceMode) + } + if !next.DevMode { + t.Fatal("expected dev mode to stay enabled") + } +} + +func TestApplyConnectionPatchSearchSourceInFormalMode(t *testing.T) { + current := deriveConnectionPrefsFromDevMode(false) + patch := domusecase.ConnectionPrefsPatch{ + SearchSourceMode: strPtr("threads_crawler"), + } + next := applyConnectionPatch(current, patch) + if next.SearchSourceMode != "threads_crawler" { + t.Fatalf("expected threads_crawler, got %s", next.SearchSourceMode) + } +} + +func TestNormalizeConnectionPrefsKeepsCrawlerModeInFormalMode(t *testing.T) { + prefs := domusecase.ConnectionPrefs{ + DevMode: false, + SearchSourceMode: "crawler", + } + next := normalizeConnectionPrefs(prefs) + if next.SearchSourceMode != "crawler" { + t.Fatalf("expected crawler, got %s", next.SearchSourceMode) + } +} + +func strPtr(v string) *string { + return &v +} diff --git a/internal/model/threads_account/usecase/placement_context.go b/backend/internal/model/threads_account/usecase/placement_context.go similarity index 100% rename from internal/model/threads_account/usecase/placement_context.go rename to backend/internal/model/threads_account/usecase/placement_context.go diff --git a/internal/model/threads_account/usecase/placement_context_test.go b/backend/internal/model/threads_account/usecase/placement_context_test.go similarity index 100% rename from internal/model/threads_account/usecase/placement_context_test.go rename to backend/internal/model/threads_account/usecase/placement_context_test.go diff --git a/internal/model/threads_account/usecase/publish_credentials.go b/backend/internal/model/threads_account/usecase/publish_credentials.go similarity index 100% rename from internal/model/threads_account/usecase/publish_credentials.go rename to backend/internal/model/threads_account/usecase/publish_credentials.go diff --git a/internal/model/threads_account/usecase/usecase.go b/backend/internal/model/threads_account/usecase/usecase.go similarity index 90% rename from internal/model/threads_account/usecase/usecase.go rename to backend/internal/model/threads_account/usecase/usecase.go index d78c8e7..297fd34 100644 --- a/internal/model/threads_account/usecase/usecase.go +++ b/backend/internal/model/threads_account/usecase/usecase.go @@ -7,6 +7,7 @@ import ( "strings" "haixun-backend/internal/library/clock" + "haixun-backend/internal/library/crypto" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" "haixun-backend/internal/library/placement" @@ -34,6 +35,7 @@ type threadsAccountUseCase struct { members memberdomain.UseCase settings settingdomain.UseCase personas personadomain.UseCase + cipher *crypto.Cipher } func NewUseCase( @@ -42,6 +44,7 @@ func NewUseCase( members memberdomain.UseCase, settings settingdomain.UseCase, personas personadomain.UseCase, + cipher *crypto.Cipher, ) domusecase.UseCase { return &threadsAccountUseCase{ repo: repo, @@ -49,6 +52,7 @@ func NewUseCase( members: members, settings: settings, personas: personas, + cipher: cipher, } } @@ -273,6 +277,27 @@ func (u *threadsAccountUseCase) toSummary(ctx context.Context, account *entity.A }, nil } +func (u *threadsAccountUseCase) SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*domusecase.SecretsData, error) { + accountID = strings.TrimSpace(accountID) + if accountID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required") + } + accessToken = strings.TrimSpace(accessToken) + if accessToken == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("access_token is required") + } + result, err := u.secretsRepo.SaveAPIAccessToken(ctx, accountID, accessToken, expiresAt) + if err != nil { + return nil, err + } + return &domusecase.SecretsData{ + AccountID: result.AccountID, + APIAccessToken: result.APIAccessToken, + APITokenExpiresAt: result.APITokenExpiresAt, + UpdateAt: result.UpdateAt, + }, nil +} + func (u *threadsAccountUseCase) connectionFlags(ctx context.Context, accountID string) (bool, bool, error) { secrets, err := u.secretsRepo.FindByAccountID(ctx, accountID) if err != nil { @@ -341,7 +366,7 @@ func deriveConnectionPrefsFromDevMode(devMode bool) domusecase.ConnectionPrefs { return domusecase.ConnectionPrefs{ DevMode: true, SearchViaApi: false, - SearchSourceMode: "browser", + SearchSourceMode: string(placement.SearchSourceThreadsCrawler), PublishViaApi: false, ScrapeReplies: true, RepliesPerPost: defaultRepliesPerPost, @@ -364,7 +389,13 @@ func deriveConnectionPrefsFromDevMode(devMode bool) domusecase.ConnectionPrefs { func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.ConnectionPrefsPatch) domusecase.ConnectionPrefs { if patch.DevMode != nil { if *patch.DevMode { - return deriveConnectionPrefsFromDevMode(true) + next := deriveConnectionPrefsFromDevMode(true) + if patch.SearchSourceMode != nil { + next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode) + } else if strings.TrimSpace(current.SearchSourceMode) != "" { + next.SearchSourceMode = current.SearchSourceMode + } + return normalizeConnectionPrefs(next) } next := current next.DevMode = false @@ -384,10 +415,13 @@ func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.C if patch.ScrapeReplies != nil && *patch.ScrapeReplies { devMode = true } - if devMode { - return deriveConnectionPrefsFromDevMode(true) - } next := current + if devMode { + next = deriveConnectionPrefsFromDevMode(true) + if strings.TrimSpace(current.SearchSourceMode) != "" && patch.SearchSourceMode == nil { + next.SearchSourceMode = current.SearchSourceMode + } + } if patch.SearchSourceMode != nil { next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode) } @@ -395,15 +429,18 @@ func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.C } func normalizeConnectionPrefs(prefs domusecase.ConnectionPrefs) domusecase.ConnectionPrefs { + mode := placement.ParseSearchSourceMode(prefs.SearchSourceMode) if prefs.DevMode { - return deriveConnectionPrefsFromDevMode(true) + out := deriveConnectionPrefsFromDevMode(true) + out.SearchSourceMode = string(mode) + return out } + prefs.DevMode = false prefs.SearchViaApi = true prefs.PublishViaApi = true prefs.ScrapeReplies = false prefs.PublishHeaded = false prefs.PlaywrightDebug = false - mode := placement.WithoutCrawler(placement.ParseSearchSourceMode(prefs.SearchSourceMode)) prefs.SearchSourceMode = string(mode) return prefs } diff --git a/internal/response/request.go b/backend/internal/response/request.go similarity index 100% rename from internal/response/request.go rename to backend/internal/response/request.go diff --git a/internal/response/response.go b/backend/internal/response/response.go similarity index 100% rename from internal/response/response.go rename to backend/internal/response/response.go diff --git a/internal/svc/service_context.go b/backend/internal/svc/service_context.go similarity index 76% rename from internal/svc/service_context.go rename to backend/internal/svc/service_context.go index b63e021..3b807b9 100644 --- a/internal/svc/service_context.go +++ b/backend/internal/svc/service_context.go @@ -3,10 +3,12 @@ package svc import ( "context" "fmt" + "net/http" "os" "time" "haixun-backend/internal/config" + libcrypto "haixun-backend/internal/library/crypto" libmongo "haixun-backend/internal/library/mongo" libredis "haixun-backend/internal/library/redis" "haixun-backend/internal/library/validate" @@ -28,6 +30,9 @@ import ( copymissiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" copymissionrepo "haixun-backend/internal/model/copy_mission/repository" copymissionusecase "haixun-backend/internal/model/copy_mission/usecase" + crmcontactdomain "haixun-backend/internal/model/crm_contact/domain/usecase" + crmcontactrepo "haixun-backend/internal/model/crm_contact/repository" + crmcontactusecase "haixun-backend/internal/model/crm_contact/usecase" jobrepo "haixun-backend/internal/model/job/repository" jobusecase "haixun-backend/internal/model/job/usecase" kgdomain "haixun-backend/internal/model/knowledge_graph/domain/usecase" @@ -49,6 +54,9 @@ import ( placementtopicdomain "haixun-backend/internal/model/placement_topic/domain/usecase" placementtopicrepo "haixun-backend/internal/model/placement_topic/repository" placementtopicusecase "haixun-backend/internal/model/placement_topic/usecase" + publishanalyticsdomain "haixun-backend/internal/model/publish_analytics/domain/usecase" + publishanalyticsrepo "haixun-backend/internal/model/publish_analytics/repository" + publishanalyticsusecase "haixun-backend/internal/model/publish_analytics/usecase" scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase" scanpostrepo "haixun-backend/internal/model/scan_post/repository" scanpostusecase "haixun-backend/internal/model/scan_post/usecase" @@ -69,23 +77,25 @@ type ServiceContext struct { Mongo *libmongo.Client Redis *goredis.Client - Setting settingusecase.UseCase - AI aisettings.UseCase - Job jobusecase.UseCase - AuthToken authdomain.TokenUseCase - Member memberdomain.UseCase - Permission permissiondomain.UseCase - Persona personadomain.UseCase - CopyMission copymissiondomain.UseCase - Brand branddomain.UseCase - PlacementTopic placementtopicdomain.UseCase - KnowledgeGraph kgdomain.UseCase - Placement placementusecase.UseCase - ScanPost scanpostdomain.UseCase - OutreachDraft outreachdraftdomain.UseCase - ContentMatrix cmatrixdomain.UseCase - CopyDraft copydraftdomain.UseCase - ThreadsAccount threadsaccountdomain.UseCase + Setting settingusecase.UseCase + AI aisettings.UseCase + Job jobusecase.UseCase + AuthToken authdomain.TokenUseCase + Member memberdomain.UseCase + Permission permissiondomain.UseCase + Persona personadomain.UseCase + CopyMission copymissiondomain.UseCase + Brand branddomain.UseCase + PlacementTopic placementtopicdomain.UseCase + KnowledgeGraph kgdomain.UseCase + Placement placementusecase.UseCase + ScanPost scanpostdomain.UseCase + OutreachDraft outreachdraftdomain.UseCase + ContentMatrix cmatrixdomain.UseCase + CopyDraft copydraftdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + CrmContact crmcontactdomain.UseCase + PublishAnalytics publishanalyticsdomain.UseCase // Middlewares mounted per route group via generate/api `middleware:` directive. AuthJWT rest.Middleware @@ -105,6 +115,11 @@ func NewServiceContext(c config.Config) *ServiceContext { } redisClient := libredis.NewClient(c.Redis) + secretsCipher, err := libcrypto.New(c.Secrets.EncryptionKey) + if err != nil { + panic(err) + } + settingRepository := settingrepo.NewMongoRepository(mongoClient.Database()) if err := settingRepository.EnsureIndexes(ctx); err != nil { panic(err) @@ -182,6 +197,12 @@ func NewServiceContext(c config.Config) *ServiceContext { if err := jobUseCase.EnsureGenerateCopyDraftTemplate(ctx); err != nil { panic(err) } + if err := jobUseCase.EnsureRefreshThreadsTokenTemplate(ctx); err != nil { + panic(err) + } + if err := jobUseCase.EnsurePublishAnalyticsTemplate(ctx); err != nil { + panic(err) + } copyMissionRepository := copymissionrepo.NewMongoRepository(mongoClient.Database()) if err := copyMissionRepository.EnsureIndexes(ctx); err != nil { @@ -241,7 +262,7 @@ func NewServiceContext(c config.Config) *ServiceContext { scanPostRepository, ) threadsAccountRepository := threadsaccountrepo.NewMongoRepository(mongoClient.Database()) - threadsAccountSecretsRepository := threadsaccountrepo.NewSecretsMongoRepository(mongoClient.Database()) + threadsAccountSecretsRepository := threadsaccountrepo.NewSecretsMongoRepository(mongoClient.Database(), secretsCipher) if err := threadsAccountRepository.EnsureIndexes(ctx); err != nil { panic(err) } @@ -254,32 +275,47 @@ func NewServiceContext(c config.Config) *ServiceContext { memberUseCase, settingUseCase, personaUseCase, + secretsCipher, ) - placementUseCase := placementusecase.NewUseCase(settingUseCase) + placementUseCase := placementusecase.NewUseCase(settingUseCase, secretsCipher) + + crmContactRepository := crmcontactrepo.NewMongoRepository(mongoClient.Database()) + if err := crmContactRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + crmContactUseCase := crmcontactusecase.NewUseCase(crmContactRepository) + + publishAnalyticsRepository := publishanalyticsrepo.NewMongoRepository(mongoClient.Database()) + if err := publishAnalyticsRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + publishAnalyticsUseCase := publishanalyticsusecase.NewUseCase(publishAnalyticsRepository) sc := &ServiceContext{ - Config: c, - Validator: validate.New(), - Mongo: mongoClient, - Redis: redisClient, - Setting: settingUseCase, - AI: aisettings.NewUseCase(), - Job: jobUseCase, - AuthToken: authTokenUseCase, - Member: memberUseCase, - Permission: permissionUseCase, - Persona: personaUseCase, - CopyMission: copyMissionUseCase, - Brand: brandUseCase, - PlacementTopic: placementTopicUseCase, - KnowledgeGraph: knowledgeGraphUseCase, - Placement: placementUseCase, - ScanPost: scanPostUseCase, - OutreachDraft: outreachDraftUseCase, - ContentMatrix: contentMatrixUseCase, - CopyDraft: copyDraftUseCase, - ThreadsAccount: threadsAccountUseCase, + Config: c, + Validator: validate.New(), + Mongo: mongoClient, + Redis: redisClient, + Setting: settingUseCase, + AI: aisettings.NewUseCase(), + Job: jobUseCase, + AuthToken: authTokenUseCase, + Member: memberUseCase, + Permission: permissionUseCase, + Persona: personaUseCase, + CopyMission: copyMissionUseCase, + Brand: brandUseCase, + PlacementTopic: placementTopicUseCase, + KnowledgeGraph: knowledgeGraphUseCase, + Placement: placementUseCase, + ScanPost: scanPostUseCase, + OutreachDraft: outreachDraftUseCase, + ContentMatrix: contentMatrixUseCase, + CopyDraft: copyDraftUseCase, + ThreadsAccount: threadsAccountUseCase, + CrmContact: crmContactUseCase, + PublishAnalytics: publishAnalyticsUseCase, } hostname, _ := os.Hostname() @@ -349,6 +385,15 @@ func NewServiceContext(c config.Config) *ServiceContext { ThreadsAccount: threadsAccountUseCase, AI: sc.AI, }) + jobworker.RegisterRefreshThreadsTokenHandler(runner, jobworker.RefreshThreadsTokenDeps{ + Jobs: jobUseCase, + ThreadsAccount: threadsAccountUseCase, + }) + jobworker.RegisterPublishAnalyticsHandler(runner, jobworker.PublishAnalyticsDeps{ + Jobs: jobUseCase, + ThreadsAccount: threadsAccountUseCase, + PublishAnalytics: publishAnalyticsUseCase, + }) go runner.Start(workerCtx) } @@ -375,8 +420,14 @@ func NewServiceContext(c config.Config) *ServiceContext { go reaper.Start(reaperCtx, interval) } - sc.AuthJWT = middleware.NewAuthJWTMiddleware(sc.AuthToken, sc.Config.Auth).Handle - sc.MemberAuth = middleware.NewMemberAuthMiddleware(sc.AuthToken, sc.Config.Auth).Handle + // 認證 + RBAC:先驗 JWT(把 actor 放進 context),再用 catalog 權限做路由級授權。 + // 兩者組合後掛在受保護的 route group 上(routes.go 以 AuthJWT / MemberAuth 引用)。 + authJWT := middleware.NewAuthJWTMiddleware(sc.AuthToken, sc.Config.Auth).Handle + memberAuth := middleware.NewMemberAuthMiddleware(sc.AuthToken, sc.Config.Auth).Handle + rbac := middleware.NewPermissionRBACMiddleware(sc.Member, sc.Permission).Handle + + sc.AuthJWT = func(next http.HandlerFunc) http.HandlerFunc { return authJWT(rbac(next)) } + sc.MemberAuth = func(next http.HandlerFunc) http.HandlerFunc { return memberAuth(rbac(next)) } sc.WorkerSecret = middleware.NewWorkerSecretMiddleware(sc.Config.InternalWorker).Handle return sc diff --git a/internal/types/types.go b/backend/internal/types/types.go similarity index 93% rename from internal/types/types.go rename to backend/internal/types/types.go index 47c7f66..1ac15fa 100644 --- a/internal/types/types.go +++ b/backend/internal/types/types.go @@ -1185,6 +1185,28 @@ type ThreadsAccountAiSettingsData struct { ApiKeysConfigured map[string]interface{} `json:"api_keys_configured"` } +type ThreadsAccountAiProviderPath struct { + ID string `path:"id" validate:"required"` + Provider string `path:"provider" validate:"required,oneof=opencode-go xai"` +} + +type ListThreadsAccountAiProviderModelsReq struct { + ApiKey string `json:"api_key,optional"` +} + +type ThreadsAccountAiProviderModelsHandlerReq struct { + ThreadsAccountAiProviderPath + ListThreadsAccountAiProviderModelsReq +} + +type ThreadsAccountAiProviderModelsData struct { + ID string `json:"id"` + Label string `json:"label"` + Models []string `json:"models"` + Streams bool `json:"streams"` + Error string `json:"error,optional"` +} + type ThreadsAccountConnectionData struct { AccountID string `json:"account_id"` AccountName string `json:"account_name"` @@ -1426,6 +1448,97 @@ type UpsertPlacementTopicScanScheduleHandlerReq struct { UpsertBrandScanScheduleReq } +type CrmContactData struct { + ID string `json:"id"` + BrandID string `json:"brand_id,omitempty"` + AuthorID string `json:"author_id"` + AuthorName string `json:"author_name"` + AuthorAvatar string `json:"author_avatar,omitempty"` + AuthorFollowers int `json:"author_followers,omitempty"` + ScanPostID string `json:"scan_post_id,omitempty"` + Notes string `json:"notes,omitempty"` + Tags []string `json:"tags,omitempty"` + Status string `json:"status"` + OutreachCount int `json:"outreach_count,omitempty"` + LastContactedAt int64 `json:"last_contacted_at,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` +} + +type CrmContactListData struct { + Pagination PaginationData `json:"pagination"` + List []CrmContactData `json:"list"` +} + +type CrmContactIDPath struct { + ID string `path:"id" validate:"required"` +} + +type ListCrmContactsReq struct { + BrandID string `form:"brand_id,optional"` + Status string `form:"status,optional"` + Tag string `form:"tag,optional"` + Page int `form:"page,optional"` + PageSize int `form:"pageSize,optional"` +} + +type CreateCrmContactReq struct { + BrandID string `json:"brand_id,omitempty"` + AuthorID string `json:"author_id" validate:"required"` + AuthorName string `json:"author_name" validate:"required"` +} + +type UpdateCrmContactReq struct { + CrmContactIDPath + Notes *string `json:"notes,optional"` + Tags *[]string `json:"tags,optional"` + Status *string `json:"status,optional"` +} + +type PublishAnalyticsData struct { + ID string `json:"id"` + MediaID string `json:"media_id"` + Permalink string `json:"permalink,omitempty"` + PublishedAt int64 `json:"published_at"` + Checkpoint string `json:"checkpoint"` + LikeCount int `json:"like_count,omitempty"` + ReplyCount int `json:"reply_count,omitempty"` + RepostCount int `json:"repost_count,omitempty"` + QuoteCount int `json:"quote_count,omitempty"` + ImpressionCount int `json:"impression_count,omitempty"` + ReachCount int `json:"reach_count,omitempty"` + CheckedAt int64 `json:"checked_at"` + CreateAt int64 `json:"create_at"` +} + +type PublishAnalyticsListData struct { + List []PublishAnalyticsData `json:"list"` +} + +type LookalikeReq struct { + SourcePostID string `json:"source_post_id" validate:"required"` + BrandID string `json:"brand_id,optional"` + Limit int `json:"limit,optional"` +} + +type LookalikeItemData struct { + PostID string `json:"post_id"` + AuthorID string `json:"author_id"` + AuthorName string `json:"author_name"` + AuthorAvatar string `json:"author_avatar,omitempty"` + Permalink string `json:"permalink"` + Text string `json:"text"` + PlacementScore int `json:"placement_score"` + SemanticScore int `json:"semantic_score"` + EngagementPredicted int `json:"engagement_predicted"` + AudienceQuality int `json:"audience_quality"` + Similarity float64 `json:"similarity"` +} + +type LookalikeData struct { + List []LookalikeItemData `json:"list"` +} + type ViralScanPostData struct { ID string `json:"id"` SearchTag string `json:"search_tag"` diff --git a/internal/worker/job/analyze_copy_mission.go b/backend/internal/worker/job/analyze_copy_mission.go similarity index 100% rename from internal/worker/job/analyze_copy_mission.go rename to backend/internal/worker/job/analyze_copy_mission.go diff --git a/internal/worker/job/crawler_search.go b/backend/internal/worker/job/crawler_search.go similarity index 100% rename from internal/worker/job/crawler_search.go rename to backend/internal/worker/job/crawler_search.go diff --git a/internal/worker/job/expand_graph.go b/backend/internal/worker/job/expand_graph.go similarity index 98% rename from internal/worker/job/expand_graph.go rename to backend/internal/worker/job/expand_graph.go index 4ea3d4b..c0df628 100644 --- a/internal/worker/job/expand_graph.go +++ b/backend/internal/worker/job/expand_graph.go @@ -2,6 +2,7 @@ package job import ( "context" + "encoding/json" "fmt" "strings" "sync" @@ -543,6 +544,25 @@ func mergeGraphs(existing *kgusecase.GraphSummary, incoming libkg.Graph, extraSo return merged } +func int64Field(payload map[string]any, key string) int64 { + if payload == nil { + return 0 + } + switch v := payload[key].(type) { + case float64: + return int64(v) + case int64: + return v + case int: + return int64(v) + case json.Number: + n, _ := v.Int64() + return n + default: + return 0 + } +} + func stringField(payload map[string]any, key string) string { if payload == nil { return "" diff --git a/internal/worker/job/generate_copy_draft.go b/backend/internal/worker/job/generate_copy_draft.go similarity index 100% rename from internal/worker/job/generate_copy_draft.go rename to backend/internal/worker/job/generate_copy_draft.go diff --git a/internal/worker/job/generate_copy_matrix.go b/backend/internal/worker/job/generate_copy_matrix.go similarity index 100% rename from internal/worker/job/generate_copy_matrix.go rename to backend/internal/worker/job/generate_copy_matrix.go diff --git a/internal/worker/job/payload_actor.go b/backend/internal/worker/job/payload_actor.go similarity index 100% rename from internal/worker/job/payload_actor.go rename to backend/internal/worker/job/payload_actor.go diff --git a/internal/worker/job/placement_scope.go b/backend/internal/worker/job/placement_scope.go similarity index 100% rename from internal/worker/job/placement_scope.go rename to backend/internal/worker/job/placement_scope.go diff --git a/backend/internal/worker/job/publish_analytics.go b/backend/internal/worker/job/publish_analytics.go new file mode 100644 index 0000000..8520bf6 --- /dev/null +++ b/backend/internal/worker/job/publish_analytics.go @@ -0,0 +1,81 @@ +package job + +import ( + "context" + "fmt" + "strings" + + libthreads "haixun-backend/internal/library/threadsapi" + jobdom "haixun-backend/internal/model/job/domain/usecase" + publishanalyticsdomain "haixun-backend/internal/model/publish_analytics/domain/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type PublishAnalyticsDeps struct { + Jobs jobdom.UseCase + ThreadsAccount threadsaccountdomain.UseCase + PublishAnalytics publishanalyticsdomain.UseCase +} + +func RegisterPublishAnalyticsHandler(runner *Runner, deps PublishAnalyticsDeps) { + if runner == nil { + return + } + runner.RegisterStepHandler("check", func(ctx context.Context, step StepContext) error { + return runPublishAnalyticsCheck(ctx, step, deps) + }) +} + +func runPublishAnalyticsCheck(ctx context.Context, step StepContext, deps PublishAnalyticsDeps) error { + payload := step.Run.Payload + tenantID, ownerUID := runActorFromPayload(payload, step.Run) + mediaID := strings.TrimSpace(stringField(payload, "media_id")) + checkpoint := strings.TrimSpace(stringField(payload, "checkpoint")) + permalink := strings.TrimSpace(stringField(payload, "permalink")) + publishedAt := int64Field(payload, "published_at") + if mediaID == "" || checkpoint == "" { + return fmt.Errorf("publish-analytics payload missing media_id or checkpoint") + } + + cred, err := deps.ThreadsAccount.ResolveMemberThreadsPublishCredential(ctx, tenantID, ownerUID) + if err != nil { + return fmt.Errorf("resolve publish credential: %w", err) + } + if cred == nil || cred.AccessToken == "" { + return fmt.Errorf("no access token found") + } + + stats, err := libthreads.GetMediaStats(ctx, cred.AccessToken, mediaID) + if err != nil { + return fmt.Errorf("get media stats: %w", err) + } + + saved, err := deps.PublishAnalytics.RecordCheckpoint(ctx, tenantID, ownerUID, publishanalyticsdomain.PublishAnalyticsSummary{ + MediaID: mediaID, + Permalink: permalink, + PublishedAt: publishedAt, + Checkpoint: checkpoint, + LikeCount: stats.LikeCount, + ReplyCount: stats.ReplyCount, + RepostCount: stats.RepostCount, + QuoteCount: stats.QuoteCount, + }) + if err != nil { + return fmt.Errorf("record checkpoint: %w", err) + } + + _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{ + JobID: step.JobID, + WorkerID: step.WorkerID, + Result: map[string]any{ + "media_id": mediaID, + "checkpoint": checkpoint, + "like_count": saved.LikeCount, + "reply_count": saved.ReplyCount, + "repost_count": saved.RepostCount, + "quote_count": saved.QuoteCount, + "analytics_id": saved.ID, + }, + }) + return err +} diff --git a/internal/worker/job/reaper.go b/backend/internal/worker/job/reaper.go similarity index 100% rename from internal/worker/job/reaper.go rename to backend/internal/worker/job/reaper.go diff --git a/backend/internal/worker/job/refresh_threads_token.go b/backend/internal/worker/job/refresh_threads_token.go new file mode 100644 index 0000000..d33668d --- /dev/null +++ b/backend/internal/worker/job/refresh_threads_token.go @@ -0,0 +1,63 @@ +package job + +import ( + "context" + "fmt" + "strings" + + libthreads "haixun-backend/internal/library/threadsapi" + jobdom "haixun-backend/internal/model/job/domain/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type RefreshThreadsTokenDeps struct { + Jobs jobdom.UseCase + ThreadsAccount threadsaccountdomain.UseCase +} + +func RegisterRefreshThreadsTokenHandler(runner *Runner, deps RefreshThreadsTokenDeps) { + if runner == nil { + return + } + runner.RegisterStepHandler("refresh", func(ctx context.Context, step StepContext) error { + return runRefreshThreadsToken(ctx, step, deps) + }) +} + +func runRefreshThreadsToken(ctx context.Context, step StepContext, deps RefreshThreadsTokenDeps) error { + payload := step.Run.Payload + tenantID, ownerUID := runActorFromPayload(payload, step.Run) + accountID := strings.TrimSpace(stringField(payload, "account_id")) + if tenantID == "" || ownerUID == "" || accountID == "" { + return fmt.Errorf("refresh-threads-token payload missing tenant_id, owner_uid, or account_id") + } + + cred, err := deps.ThreadsAccount.ResolveMemberThreadsPublishCredential(ctx, tenantID, ownerUID) + if err != nil { + return fmt.Errorf("resolve publish credential: %w", err) + } + if cred == nil || cred.AccessToken == "" { + return fmt.Errorf("no access token found for account %s", accountID) + } + + result, err := libthreads.RefreshAccessToken(ctx, cred.AccessToken) + if err != nil { + return fmt.Errorf("refresh token: %w", err) + } + + expiresAt := libthreads.NsFromNow(result.ExpiresIn) + if _, err := deps.ThreadsAccount.SaveAPIAccessToken(ctx, accountID, result.AccessToken, expiresAt); err != nil { + return fmt.Errorf("save refreshed token: %w", err) + } + + _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{ + JobID: step.JobID, + WorkerID: step.WorkerID, + Result: map[string]any{ + "account_id": accountID, + "expires_at": expiresAt, + "token_short": result.AccessToken[:min(len(result.AccessToken), 8)] + "...", + }, + }) + return err +} diff --git a/internal/worker/job/runner.go b/backend/internal/worker/job/runner.go similarity index 89% rename from internal/worker/job/runner.go rename to backend/internal/worker/job/runner.go index b9e7c6f..e68d554 100644 --- a/internal/worker/job/runner.go +++ b/backend/internal/worker/job/runner.go @@ -111,14 +111,16 @@ func (r *Runner) execute(ctx context.Context, run *entity.Run) { } } percentage := (completedBefore * 100) / totalSteps - _, _ = r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{ + if _, err := r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{ JobID: jobID, WorkerID: r.workerID, Phase: step.ID, Summary: "running step " + step.ID, Percentage: percentage, Steps: steps, - }) + }); err != nil { + log.Printf("job worker update progress (running) failed: job=%s step=%s err=%v", jobID, step.ID, err) + } if err := r.runStep(ctx, run, template, step); err != nil { if err == errJobCancelled { @@ -155,28 +157,32 @@ func (r *Runner) execute(ctx context.Context, run *entity.Run) { } completedBefore++ percentage = (completedBefore * 100) / totalSteps - _, _ = r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{ + if _, err := r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{ JobID: jobID, WorkerID: r.workerID, Phase: step.ID, Summary: "completed step " + step.ID, Percentage: percentage, Steps: steps, - }) + }); err != nil { + log.Printf("job worker update progress (completed step) failed: job=%s step=%s err=%v", jobID, step.ID, err) + } } fresh, err := r.jobs.GetRun(ctx, jobID) if err == nil && fresh != nil && fresh.Status.IsTerminal() { return } - _, _ = r.jobs.CompleteRun(ctx, domusecase.CompleteRunRequest{ + if _, err := r.jobs.CompleteRun(ctx, domusecase.CompleteRunRequest{ JobID: jobID, WorkerID: r.workerID, Result: map[string]any{ - "message": "demo long task completed", - "steps": len(template.Steps), + "template": run.TemplateType, + "steps": len(template.Steps), }, - }) + }); err != nil { + log.Printf("job worker complete run failed: job=%s err=%v", jobID, err) + } } var errJobCancelled = errors.New("job cancelled") diff --git a/internal/worker/job/runner_test.go b/backend/internal/worker/job/runner_test.go similarity index 100% rename from internal/worker/job/runner_test.go rename to backend/internal/worker/job/runner_test.go diff --git a/internal/worker/job/scan_placement.go b/backend/internal/worker/job/scan_placement.go similarity index 100% rename from internal/worker/job/scan_placement.go rename to backend/internal/worker/job/scan_placement.go diff --git a/internal/worker/job/scan_viral.go b/backend/internal/worker/job/scan_viral.go similarity index 100% rename from internal/worker/job/scan_viral.go rename to backend/internal/worker/job/scan_viral.go diff --git a/internal/worker/job/scheduler.go b/backend/internal/worker/job/scheduler.go similarity index 100% rename from internal/worker/job/scheduler.go rename to backend/internal/worker/job/scheduler.go diff --git a/backend/worker/package-lock.json b/backend/worker/package-lock.json new file mode 100644 index 0000000..da2729d --- /dev/null +++ b/backend/worker/package-lock.json @@ -0,0 +1,593 @@ +{ + "name": "haixun-node-worker", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "haixun-node-worker", + "dependencies": { + "playwright": "^1.49.1" + }, + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/worker/package.json b/backend/worker/package.json similarity index 100% rename from worker/package.json rename to backend/worker/package.json diff --git a/worker/style-8d-worker.ts b/backend/worker/style-8d-worker.ts similarity index 100% rename from worker/style-8d-worker.ts rename to backend/worker/style-8d-worker.ts diff --git a/worker/threads-keyword-search-cli.ts b/backend/worker/threads-keyword-search-cli.ts similarity index 100% rename from worker/threads-keyword-search-cli.ts rename to backend/worker/threads-keyword-search-cli.ts diff --git a/worker/threads-keyword-search.ts b/backend/worker/threads-keyword-search.ts similarity index 100% rename from worker/threads-keyword-search.ts rename to backend/worker/threads-keyword-search.ts diff --git a/worker/threads-profile-scraper.ts b/backend/worker/threads-profile-scraper.ts similarity index 100% rename from worker/threads-profile-scraper.ts rename to backend/worker/threads-profile-scraper.ts diff --git a/deploy/.env b/deploy/.env deleted file mode 100644 index 973bdbe..0000000 --- a/deploy/.env +++ /dev/null @@ -1,40 +0,0 @@ -# 複製為 deploy/.env 後再啟動:cp deploy/.env.example deploy/.env - -# ── 對外埠 ── -HAIXUN_WEB_PORT=8080 - -# ── 前端打包模式 ── -# static = 本機 make web-build 後 nginx 只 COPY dist(預設,最快) -# docker = 在 Docker 內跑 npm build(需改 compose 用 Dockerfile.web) -# HAIXUN_WEB_BUILD_MODE=static - -# ── Worker 分身數(make prod 會帶入 docker compose --scale)── -GO_WORKER_REPLICAS=5 -NODE_STYLE8D_WORKER_REPLICAS=5 - -# ── Mongo / Redis(容器內預設,通常不用改)── -# 資料存在 Docker named volume:haixun-prod_mongo_data、haixun-prod_redis_data -# prod-down 不會刪 volume;重啟 container 資料仍在。 -# 只改版程式:make prod-update(不碰 mongo/redis) -HAIXUN_MONGO_URI=mongodb://mongo:27017 -HAIXUN_MONGO_DATABASE=haixun -HAIXUN_REDIS_ADDR=redis:6379 - -# ── 安全金鑰(正式環境務必更換)── -HAIXUN_AUTH_ACCESS_SECRET=change-me-access-secret -HAIXUN_AUTH_REFRESH_SECRET=change-me-refresh-secret -HAIXUN_WORKER_SECRET=change-me-worker-secret - -# ── 首次初始化管理員(make prod 會自動跑 init;已存在則跳過建立)── -INIT_TENANT_ID=default -INIT_ADMIN_EMAIL=admin@30cm.net -INIT_ADMIN_PASSWORD=Fafafa54088 - -# ── Node 8D worker 選項 ── -# HAIXUN_NODE_WORKER_ID=custom-node-worker-1 -# HAIXUN_WORKER_POLL_MS=3000 - -# ── 略過自動 init ── -# 預設:若 Mongo 已有 members 會自動跳過 init。 -# 強制重跑 init:PROD_FORCE_INIT=1 make prod -# HAIXUN_SKIP_INIT=1 \ No newline at end of file diff --git a/deploy/.env.example b/deploy/.env.example deleted file mode 100644 index 88ff0f0..0000000 --- a/deploy/.env.example +++ /dev/null @@ -1,40 +0,0 @@ -# 複製為 deploy/.env 後再啟動:cp deploy/.env.example deploy/.env - -# ── 對外埠 ── -HAIXUN_WEB_PORT=8080 - -# ── 前端打包模式 ── -# static = 本機 make web-build 後 nginx 只 COPY dist(預設,最快) -# docker = 在 Docker 內跑 npm build(需改 compose 用 Dockerfile.web) -# HAIXUN_WEB_BUILD_MODE=static - -# ── Worker 分身數(make prod 會帶入 docker compose --scale)── -GO_WORKER_REPLICAS=1 -NODE_STYLE8D_WORKER_REPLICAS=1 - -# ── Mongo / Redis(容器內預設,通常不用改)── -# 資料存在 Docker named volume:haixun-prod_mongo_data、haixun-prod_redis_data -# prod-down 不會刪 volume;重啟 container 資料仍在。 -# 只改版程式:make prod-update(不碰 mongo/redis) -HAIXUN_MONGO_URI=mongodb://mongo:27017 -HAIXUN_MONGO_DATABASE=haixun -HAIXUN_REDIS_ADDR=redis:6379 - -# ── 安全金鑰(正式環境務必更換)── -HAIXUN_AUTH_ACCESS_SECRET=change-me-access-secret -HAIXUN_AUTH_REFRESH_SECRET=change-me-refresh-secret -HAIXUN_WORKER_SECRET=change-me-worker-secret - -# ── 首次初始化管理員(make prod 會自動跑 init;已存在則跳過建立)── -INIT_TENANT_ID=default -INIT_ADMIN_EMAIL=admin@haixun.local -INIT_ADMIN_PASSWORD=Admin-Pass-1! - -# ── Node 8D worker 選項 ── -# HAIXUN_NODE_WORKER_ID=custom-node-worker-1 -# HAIXUN_WORKER_POLL_MS=3000 - -# ── 略過自動 init ── -# 預設:若 Mongo 已有 members 會自動跳過 init。 -# 強制重跑 init:PROD_FORCE_INIT=1 make prod -# HAIXUN_SKIP_INIT=1 \ No newline at end of file diff --git a/deploy/Dockerfile.api b/deploy/Dockerfile.api deleted file mode 100644 index dafaee4..0000000 --- a/deploy/Dockerfile.api +++ /dev/null @@ -1,22 +0,0 @@ -# syntax=docker/dockerfile:1 - -FROM golang:1.22-bookworm AS builder -WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download -COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/haixun-api . -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/haixun-worker ./cmd/worker -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/haixun-tool ./cmd/tool - -FROM debian:bookworm-slim -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates gettext-base curl \ - && rm -rf /var/lib/apt/lists/* -WORKDIR /app -COPY --from=builder /out/haixun-api /out/haixun-worker /out/haixun-tool /app/ -COPY deploy/config/gateway.runtime.yaml.tpl deploy/config/gateway.worker.runtime.yaml.tpl /app/deploy/config/ -COPY deploy/docker/entrypoint-api.sh deploy/docker/entrypoint-worker.sh deploy/docker/entrypoint-init.sh /app/deploy/docker/ -RUN chmod +x /app/deploy/docker/entrypoint-api.sh /app/deploy/docker/entrypoint-worker.sh /app/deploy/docker/entrypoint-init.sh -EXPOSE 8890 -ENTRYPOINT ["/app/deploy/docker/entrypoint-api.sh"] \ No newline at end of file diff --git a/deploy/Dockerfile.node-worker b/deploy/Dockerfile.node-worker deleted file mode 100644 index c15ec93..0000000 --- a/deploy/Dockerfile.node-worker +++ /dev/null @@ -1,9 +0,0 @@ -# syntax=docker/dockerfile:1 - -FROM mcr.microsoft.com/playwright:v1.49.1-noble AS base -WORKDIR /app -COPY worker/package.json ./ -RUN npm install -COPY worker/ ./ -ENV NODE_ENV=production -CMD ["npx", "tsx", "style-8d-worker.ts"] \ No newline at end of file diff --git a/deploy/Dockerfile.web b/deploy/Dockerfile.web deleted file mode 100644 index 0159b78..0000000 --- a/deploy/Dockerfile.web +++ /dev/null @@ -1,14 +0,0 @@ -# syntax=docker/dockerfile:1 -# 備用:無本機 Node 時在 Docker 內編譯。預設請用 Dockerfile.web.static + make web-build。 - -FROM node:22-bookworm AS web-builder -WORKDIR /src/web -COPY web/package.json web/package-lock.json ./ -RUN npm ci -COPY web/ ./ -RUN npm run build - -FROM nginx:1.27-alpine -COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=web-builder /src/web/dist /usr/share/nginx/html -EXPOSE 80 \ No newline at end of file diff --git a/deploy/Dockerfile.web.static b/deploy/Dockerfile.web.static deleted file mode 100644 index e6f5999..0000000 --- a/deploy/Dockerfile.web.static +++ /dev/null @@ -1,7 +0,0 @@ -# syntax=docker/dockerfile:1 -# 本機先執行 make web-build,再打包純靜態檔 + nginx(無 Node 編譯,建置最快) - -FROM nginx:1.27-alpine -COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf -COPY web/dist /usr/share/nginx/html -EXPOSE 80 \ No newline at end of file diff --git a/deploy/README.md b/deploy/README.md deleted file mode 100644 index e0f0a17..0000000 --- a/deploy/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# 本機依賴(Docker Compose) - -Gateway 啟用 **Notification** / **Member OTP** 需要: - -| 服務 | 用途 | 預設埠 | -|------|------|--------| -| **MongoDB** | `notifications`、`notification_dlq` collections | 27017 | -| **Redis** | 冪等、配額、異步重試佇列、member OTP challenge | 6379 | -| MailHog(選用) | 本機 SMTP 測試 | 1025 / 8025 | -| OpenLDAP(`make ldap-up` / `make k6-up`) | ZITADEL LDAP IdP 本機目錄 | 389 | -| ZITADEL(`make k6-up`) | OIDC / Social / LDAP 登入 | 8080 | - -Mongo **不需要**事先手動建 collection;應用程式寫入時會自動建立。索引由 init script 或 `make mongo-index` 建立。 - -## 快速開始 - -```bash -# 1. 啟動 Mongo + Redis -make deps-up - -# 2.(選用)含 MailHog -make deps-up-smtp - -# 3. 確認索引(首次 docker volume 通常已由 init 建立;可再跑一次保險) -make mongo-index - -# 4. 啟動 Gateway(使用 etc/gateway.dev.yaml) -make run-dev -``` - -## Mongo collections - -| Collection | 模組 | 說明 | -|------------|------|------| -| `notifications` | notification | 發送紀錄、冪等 | -| `notification_dlq` | notification | 超過 MaxRetry 的死信 | - -索引定義見 [`deploy/mongo/init/01-gateway-indexes.js`](mongo/init/01-gateway-indexes.js),與 Go 的 `Index20260520001UP` 一致。 - -## 常用指令 - -```bash -make deps-up # docker compose up -d mongo redis -make deps-up-smtp # 再加上 mailhog(profile smtp) -make ldap-up # 只起 OpenLDAP(profile ldap) -make k6-up # 全棧含 OpenLDAP + ZITADEL(見 deploy/zitadel、deploy/openldap README) -make ldap-test # 確認 LDAP 測試帳號 alice/bob -make deps-down # 停止並移除容器(保留 volume) -make deps-down-v # 停止並刪除 volume(會清掉 Mongo 資料) -make deps-logs # 查看 log -make mongo-index # 手動建立/補齊索引 -``` - -LDAP 本機測試:[deploy/openldap/README.md](openldap/README.md) - -## 連線設定 - -設定說明:[`etc/README.md`](../etc/README.md) - -| 檔案 | 用途 | -|------|------| -| [`etc/gateway.yaml`](../etc/gateway.yaml) | 預設,無需 Docker | -| [`etc/gateway.dev.example.yaml`](../etc/gateway.dev.example.yaml) | 範例(可提交) | -| `etc/gateway.dev.yaml` | 本機專用(**勿提交**,見 `.gitignore`) | diff --git a/deploy/config/gateway.runtime.yaml.tpl b/deploy/config/gateway.runtime.yaml.tpl deleted file mode 100644 index 83a5f82..0000000 --- a/deploy/config/gateway.runtime.yaml.tpl +++ /dev/null @@ -1,35 +0,0 @@ -Name: haixun-backend -Host: 0.0.0.0 -Port: 8890 -Timeout: 120000 - -Mongo: - URI: ${HAIXUN_MONGO_URI} - Database: ${HAIXUN_MONGO_DATABASE} - TimeoutSeconds: 10 - -Redis: - Addr: ${HAIXUN_REDIS_ADDR} - DB: 0 - -Auth: - AccessSecret: ${HAIXUN_AUTH_ACCESS_SECRET} - RefreshSecret: ${HAIXUN_AUTH_REFRESH_SECRET} - AccessExpireSeconds: 900 - RefreshExpireSeconds: 2592000 - DevHeaderFallback: false - -InternalWorker: - Secret: ${HAIXUN_WORKER_SECRET} - -JobWorker: - Enabled: false - WorkerType: go - -JobScheduler: - Enabled: true - IntervalSeconds: 60 - -JobReaper: - Enabled: true - IntervalSeconds: 30 \ No newline at end of file diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml deleted file mode 100644 index 6333d6d..0000000 --- a/deploy/docker-compose.prod.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: haixun-prod - -services: - mongo: - image: mongo:7 - restart: unless-stopped - environment: - MONGO_INITDB_DATABASE: haixun - # named volume:重啟/改版不會清資料(只有 prod-wipe-data 或 docker volume rm 才會) - volumes: - - mongo_data:/data/db - healthcheck: - test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] - interval: 5s - timeout: 5s - retries: 12 - start_period: 15s - - redis: - image: redis:7-alpine - restart: unless-stopped - command: ["redis-server", "--appendonly", "yes"] - # AOF + named volume:重啟後 queue/lock 狀態可從磁碟恢復 - volumes: - - redis_data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 3s - retries: 12 - - api: - build: - context: .. - dockerfile: deploy/Dockerfile.api - restart: unless-stopped - env_file: - - .env - depends_on: - mongo: - condition: service_healthy - redis: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8890/api/v1/health >/dev/null || exit 1"] - interval: 10s - timeout: 5s - retries: 12 - start_period: 20s - - go-worker: - build: - context: .. - dockerfile: deploy/Dockerfile.api - restart: unless-stopped - entrypoint: ["/app/deploy/docker/entrypoint-worker.sh"] - env_file: - - .env - depends_on: - mongo: - condition: service_healthy - redis: - condition: service_healthy - api: - condition: service_healthy - - node-worker-style-8d: - build: - context: .. - dockerfile: deploy/Dockerfile.node-worker - restart: unless-stopped - env_file: - - .env - environment: - HAIXUN_BACKEND_URL: http://api:8890 - HAIXUN_WORKER_SECRET: ${HAIXUN_WORKER_SECRET} - HAIXUN_NODE_WORKER_ID: ${HAIXUN_NODE_WORKER_ID:-} - HAIXUN_WORKER_POLL_MS: ${HAIXUN_WORKER_POLL_MS:-3000} - depends_on: - api: - condition: service_healthy - - web: - build: - context: .. - dockerfile: deploy/Dockerfile.web.static - restart: unless-stopped - ports: - - "${HAIXUN_WEB_PORT:-8080}:80" - depends_on: - api: - condition: service_healthy - - init: - profiles: ["init"] - build: - context: .. - dockerfile: deploy/Dockerfile.api - entrypoint: ["/app/deploy/docker/entrypoint-init.sh"] - env_file: - - .env - depends_on: - mongo: - condition: service_healthy - redis: - condition: service_healthy - -volumes: - mongo_data: - redis_data: \ No newline at end of file diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml deleted file mode 100644 index 3313722..0000000 --- a/deploy/docker-compose.yml +++ /dev/null @@ -1,37 +0,0 @@ -services: - mongo: - image: mongo:7 - container_name: gateway-mongo - restart: unless-stopped - ports: - - "27017:27017" - environment: - MONGO_INITDB_DATABASE: gateway - volumes: - - mongo_data:/data/db - - ./mongo/init:/docker-entrypoint-initdb.d:ro - healthcheck: - test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] - interval: 5s - timeout: 5s - retries: 10 - start_period: 10s - - redis: - image: redis:7-alpine - container_name: gateway-redis - restart: unless-stopped - ports: - - "6379:6379" - command: ["redis-server", "--appendonly", "yes"] - volumes: - - redis_data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 3s - retries: 10 - -volumes: - mongo_data: - redis_data: \ No newline at end of file diff --git a/deploy/docker/entrypoint-api.sh b/deploy/docker/entrypoint-api.sh deleted file mode 100644 index daf8b4d..0000000 --- a/deploy/docker/entrypoint-api.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/sh -set -eu - -: "${HAIXUN_MONGO_URI:=mongodb://mongo:27017}" -: "${HAIXUN_MONGO_DATABASE:=haixun}" -: "${HAIXUN_REDIS_ADDR:=redis:6379}" -: "${HAIXUN_AUTH_ACCESS_SECRET:?HAIXUN_AUTH_ACCESS_SECRET is required}" -: "${HAIXUN_AUTH_REFRESH_SECRET:?HAIXUN_AUTH_REFRESH_SECRET is required}" -: "${HAIXUN_WORKER_SECRET:?HAIXUN_WORKER_SECRET is required}" - -export HAIXUN_MONGO_URI HAIXUN_MONGO_DATABASE HAIXUN_REDIS_ADDR -export HAIXUN_AUTH_ACCESS_SECRET HAIXUN_AUTH_REFRESH_SECRET HAIXUN_WORKER_SECRET - -envsubst < /app/deploy/config/gateway.runtime.yaml.tpl > /tmp/gateway.runtime.yaml -exec /app/haixun-api -f /tmp/gateway.runtime.yaml \ No newline at end of file diff --git a/deploy/docker/entrypoint-init.sh b/deploy/docker/entrypoint-init.sh deleted file mode 100644 index 7e730f1..0000000 --- a/deploy/docker/entrypoint-init.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/sh -set -eu - -: "${HAIXUN_MONGO_URI:=mongodb://mongo:27017}" -: "${HAIXUN_MONGO_DATABASE:=haixun}" -: "${HAIXUN_REDIS_ADDR:=redis:6379}" -: "${HAIXUN_AUTH_ACCESS_SECRET:?HAIXUN_AUTH_ACCESS_SECRET is required}" -: "${HAIXUN_AUTH_REFRESH_SECRET:?HAIXUN_AUTH_REFRESH_SECRET is required}" -: "${HAIXUN_WORKER_SECRET:?HAIXUN_WORKER_SECRET is required}" -: "${INIT_TENANT_ID:=default}" -: "${INIT_ADMIN_EMAIL:=admin@haixun.local}" -: "${INIT_ADMIN_PASSWORD:?INIT_ADMIN_PASSWORD is required}" - -export HAIXUN_MONGO_URI HAIXUN_MONGO_DATABASE HAIXUN_REDIS_ADDR -export HAIXUN_AUTH_ACCESS_SECRET HAIXUN_AUTH_REFRESH_SECRET HAIXUN_WORKER_SECRET - -envsubst < /app/deploy/config/gateway.runtime.yaml.tpl > /tmp/gateway.runtime.yaml -exec /app/haixun-tool init \ - -f /tmp/gateway.runtime.yaml \ - -tenant "$INIT_TENANT_ID" \ - -email "$INIT_ADMIN_EMAIL" \ - -password "$INIT_ADMIN_PASSWORD" \ No newline at end of file diff --git a/deploy/docker/entrypoint-worker.sh b/deploy/docker/entrypoint-worker.sh deleted file mode 100644 index bb6bd0b..0000000 --- a/deploy/docker/entrypoint-worker.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/sh -set -eu - -: "${HAIXUN_MONGO_URI:=mongodb://mongo:27017}" -: "${HAIXUN_MONGO_DATABASE:=haixun}" -: "${HAIXUN_REDIS_ADDR:=redis:6379}" -: "${HAIXUN_AUTH_ACCESS_SECRET:?HAIXUN_AUTH_ACCESS_SECRET is required}" -: "${HAIXUN_AUTH_REFRESH_SECRET:?HAIXUN_AUTH_REFRESH_SECRET is required}" -: "${HAIXUN_WORKER_SECRET:?HAIXUN_WORKER_SECRET is required}" - -export HAIXUN_MONGO_URI HAIXUN_MONGO_DATABASE HAIXUN_REDIS_ADDR -export HAIXUN_AUTH_ACCESS_SECRET HAIXUN_AUTH_REFRESH_SECRET HAIXUN_WORKER_SECRET - -envsubst < /app/deploy/config/gateway.worker.runtime.yaml.tpl > /tmp/gateway.worker.runtime.yaml -exec /app/haixun-worker -f /tmp/gateway.worker.runtime.yaml \ No newline at end of file diff --git a/deploy/mongo/init/01-gateway-indexes.js b/deploy/mongo/init/01-gateway-indexes.js deleted file mode 100644 index 82ff7b3..0000000 --- a/deploy/mongo/init/01-gateway-indexes.js +++ /dev/null @@ -1,31 +0,0 @@ -// Gateway MongoDB 初始化(僅在 data volume 首次建立時執行) -// 與 internal/model/notification/repository/* Index20260520001UP 對齊 -// 既有 volume 請執行:make mongo-index - -db = db.getSiblingDB('gateway'); - -print('Creating indexes on notifications...'); - -db.notifications.createIndex( - { tenant_id: 1, kind: 1, idempotency_key: 1 }, - { unique: true, name: 'idx_notifications_tenant_kind_idempotency' } -); - -db.notifications.createIndex( - { tenant_id: 1, uid: 1, occurred_at: -1 }, - { name: 'idx_notifications_tenant_uid_occurred' } -); - -db.notifications.createIndex( - { status: 1, attempts: 1, occurred_at: 1 }, - { name: 'idx_notifications_status_attempts_occurred' } -); - -print('Creating indexes on notification_dlq...'); - -db.notification_dlq.createIndex( - { tenant_id: 1, occurred_at: -1 }, - { name: 'idx_notification_dlq_tenant_occurred' } -); - -print('Gateway Mongo init done.'); diff --git a/deploy/nginx.conf b/deploy/nginx.conf deleted file mode 100644 index 7ea24d1..0000000 --- a/deploy/nginx.conf +++ /dev/null @@ -1,55 +0,0 @@ -server { - listen 80; - server_name _; - root /usr/share/nginx/html; - index index.html; - - gzip on; - gzip_comp_level 5; - gzip_min_length 256; - gzip_types - text/css - text/javascript - application/javascript - application/json - application/xml - image/svg+xml; - - # Vite 產物:檔名含 hash,可長期快取 - location /assets/ { - add_header Cache-Control "public, max-age=31536000, immutable"; - try_files $uri =404; - } - - location /downloads/ { - add_header Cache-Control "public, max-age=86400"; - try_files $uri =404; - } - - location /illustrations/ { - add_header Cache-Control "public, max-age=86400"; - try_files $uri =404; - } - - # SPA 入口與路由:不快取,避免部署後仍載入舊版 shell - location = /index.html { - add_header Cache-Control "no-cache"; - try_files $uri =404; - } - - location /api/ { - proxy_pass http://api:8890; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_buffering off; - proxy_read_timeout 3600s; - proxy_send_timeout 3600s; - } - - location / { - try_files $uri $uri/ /index.html; - } -} \ No newline at end of file diff --git a/etc/gateway.prod.yaml b/etc/gateway.prod.yaml deleted file mode 100644 index d833f4d..0000000 --- a/etc/gateway.prod.yaml +++ /dev/null @@ -1,35 +0,0 @@ -Name: haixun-backend -Host: 0.0.0.0 -Port: 8890 -Timeout: 120000 - -Mongo: - URI: mongodb://mongo:27017 - Database: haixun - TimeoutSeconds: 10 - -Redis: - Addr: redis:6379 - DB: 0 - -Auth: - AccessSecret: change-me-in-prod - RefreshSecret: change-me-in-prod-too - AccessExpireSeconds: 900 - RefreshExpireSeconds: 2592000 - DevHeaderFallback: false - -InternalWorker: - Secret: change-me-worker-secret - -JobWorker: - Enabled: false - WorkerType: go - -JobScheduler: - Enabled: true - IntervalSeconds: 60 - -JobReaper: - Enabled: true - IntervalSeconds: 30 \ No newline at end of file diff --git a/etc/gateway.worker.yaml b/etc/gateway.worker.yaml deleted file mode 100644 index ca97498..0000000 --- a/etc/gateway.worker.yaml +++ /dev/null @@ -1,35 +0,0 @@ -Name: haixun-worker -Host: 0.0.0.0 -Port: 8891 -Timeout: 120000 - -Mongo: - URI: mongodb://mongo:27017 - Database: haixun - TimeoutSeconds: 10 - -Redis: - Addr: redis:6379 - DB: 0 - -Auth: - AccessSecret: change-me-in-prod - RefreshSecret: change-me-in-prod-too - AccessExpireSeconds: 900 - RefreshExpireSeconds: 2592000 - DevHeaderFallback: false - -InternalWorker: - Secret: change-me-worker-secret - -JobWorker: - Enabled: true - WorkerType: go - -JobScheduler: - Enabled: false - IntervalSeconds: 60 - -JobReaper: - Enabled: false - IntervalSeconds: 30 \ No newline at end of file diff --git a/web/.gitignore b/frontend/.gitignore similarity index 100% rename from web/.gitignore rename to frontend/.gitignore diff --git a/web/index.html b/frontend/index.html similarity index 100% rename from web/index.html rename to frontend/index.html diff --git a/web/package-lock.json b/frontend/package-lock.json similarity index 99% rename from web/package-lock.json rename to frontend/package-lock.json index 959380f..692517f 100644 --- a/web/package-lock.json +++ b/frontend/package-lock.json @@ -899,9 +899,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -916,9 +913,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -933,9 +927,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -950,9 +941,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -967,9 +955,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -984,9 +969,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1001,9 +983,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1018,9 +997,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1035,9 +1011,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1052,9 +1025,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1069,9 +1039,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1086,9 +1053,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1103,9 +1067,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1329,9 +1290,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1349,9 +1307,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1369,9 +1324,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1389,9 +1341,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2327,9 +2276,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2351,9 +2297,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2375,9 +2318,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2399,9 +2339,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/web/package.json b/frontend/package.json similarity index 100% rename from web/package.json rename to frontend/package.json diff --git a/web/public/downloads/haixun-threads-sync.zip b/frontend/public/downloads/haixun-threads-sync.zip similarity index 100% rename from web/public/downloads/haixun-threads-sync.zip rename to frontend/public/downloads/haixun-threads-sync.zip diff --git a/web/public/illustrations/island/airplane.svg b/frontend/public/illustrations/island/airplane.svg similarity index 100% rename from web/public/illustrations/island/airplane.svg rename to frontend/public/illustrations/island/airplane.svg diff --git a/web/public/illustrations/island/bell.svg b/frontend/public/illustrations/island/bell.svg similarity index 100% rename from web/public/illustrations/island/bell.svg rename to frontend/public/illustrations/island/bell.svg diff --git a/web/public/illustrations/island/cloud.svg b/frontend/public/illustrations/island/cloud.svg similarity index 100% rename from web/public/illustrations/island/cloud.svg rename to frontend/public/illustrations/island/cloud.svg diff --git a/web/public/illustrations/island/desert-island.svg b/frontend/public/illustrations/island/desert-island.svg similarity index 100% rename from web/public/illustrations/island/desert-island.svg rename to frontend/public/illustrations/island/desert-island.svg diff --git a/web/public/illustrations/island/herb.svg b/frontend/public/illustrations/island/herb.svg similarity index 100% rename from web/public/illustrations/island/herb.svg rename to frontend/public/illustrations/island/herb.svg diff --git a/web/public/illustrations/island/hut.svg b/frontend/public/illustrations/island/hut.svg similarity index 100% rename from web/public/illustrations/island/hut.svg rename to frontend/public/illustrations/island/hut.svg diff --git a/web/public/illustrations/island/magnifying-glass.svg b/frontend/public/illustrations/island/magnifying-glass.svg similarity index 100% rename from web/public/illustrations/island/magnifying-glass.svg rename to frontend/public/illustrations/island/magnifying-glass.svg diff --git a/web/public/illustrations/island/passport.svg b/frontend/public/illustrations/island/passport.svg similarity index 100% rename from web/public/illustrations/island/passport.svg rename to frontend/public/illustrations/island/passport.svg diff --git a/web/public/illustrations/island/sun.svg b/frontend/public/illustrations/island/sun.svg similarity index 100% rename from web/public/illustrations/island/sun.svg rename to frontend/public/illustrations/island/sun.svg diff --git a/web/public/illustrations/island/water-wave.svg b/frontend/public/illustrations/island/water-wave.svg similarity index 100% rename from web/public/illustrations/island/water-wave.svg rename to frontend/public/illustrations/island/water-wave.svg diff --git a/web/src/App.tsx b/frontend/src/App.tsx similarity index 97% rename from web/src/App.tsx rename to frontend/src/App.tsx index 737ed96..f897e37 100644 --- a/web/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import { AuthProvider } from './auth/AuthContext' import { ThemeProvider } from './theme/ThemeContext' +import { ErrorBoundary } from './components/ErrorBoundary' import { AdminRoute } from './components/AdminRoute' import { Layout } from './components/Layout' import { ProtectedRoute } from './components/ProtectedRoute' @@ -37,6 +38,7 @@ export default function App() { + } /> } /> @@ -78,6 +80,7 @@ export default function App() { } /> + diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts new file mode 100644 index 0000000..d3bf560 --- /dev/null +++ b/frontend/src/api/ai.ts @@ -0,0 +1,26 @@ +import { api } from './client' +import type { AIProviderModelsData, AIProvidersData, ThreadsAccountAiProviderModelsData } from '../types/api' + +export function fetchAiProviders() { + return api.get('/api/v1/ai/providers') +} + +export function fetchAiProviderModels(provider: string, providerToken: string) { + return api.post( + `/api/v1/ai/providers/${encodeURIComponent(provider)}/models`, + {}, + { memberAuth: true, providerToken }, + ) +} + +export function fetchAccountAiProviderModels( + accountId: string, + provider: string, + apiKey?: string, +) { + return api.post( + `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/ai-settings/providers/${encodeURIComponent(provider)}/models`, + apiKey ? { api_key: apiKey } : {}, + { auth: true }, + ) +} \ No newline at end of file diff --git a/web/src/api/client.ts b/frontend/src/api/client.ts similarity index 68% rename from web/src/api/client.ts rename to frontend/src/api/client.ts index 10a4524..1ab0ffa 100644 --- a/web/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -35,6 +35,10 @@ async function refreshTokens() { }> if (json.code !== SUCCESS_CODE || !json.data) { storage.clearSession() + // 通知 AuthProvider 清空狀態並導回登入。 + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('haixun:session-expired')) + } throw new ApiError(json.code, json.message || 'refresh failed') } storage.setAccessToken(json.data.access_token) @@ -158,6 +162,7 @@ async function consumeAIEventStream( onDelta: (text: string) => void, onDone: (finishReason?: string) => void, onError: (msg: string) => void, + signal?: AbortSignal, ) { if (!res.ok || !res.body) { onError(await readStreamErrorMessage(res)) @@ -168,39 +173,77 @@ async function consumeAIEventStream( const decoder = new TextDecoder() let buffer = '' - while (true) { - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - const parts = buffer.split('\n\n') - buffer = parts.pop() ?? '' - for (const part of parts) { - const lines = part.split('\n') - let event = '' - let data = '' - for (const line of lines) { - if (line.startsWith('event:')) event = line.slice(6).trim() - if (line.startsWith('data:')) data = line.slice(5).trim() + // 確保 onDone / onError 只會被呼叫一次:避免串流提前關閉時 + // 外層 Promise 永遠不 settle(聊天輸入永久鎖死)。 + let settled = false + const finishDone = (finishReason?: string) => { + if (settled) return + settled = true + onDone(finishReason) + } + const finishError = (msg: string) => { + if (settled) return + settled = true + onError(msg) + } + + // 回傳 true 代表收到 done/error,串流應停止。 + const handlePart = (part: string): boolean => { + const lines = part.split('\n') + let event = '' + const dataLines: string[] = [] + for (const line of lines) { + if (line.startsWith('event:')) event = line.slice(6).trim() + else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, '')) + } + const data = dataLines.join('\n').trim() + if (!data) return false + try { + const parsed = JSON.parse(data) as { + type?: string + text?: string + finish_reason?: string + message?: string } - if (!data) continue - try { - const parsed = JSON.parse(data) as { - type?: string - text?: string - finish_reason?: string - message?: string - } - if (event === 'error' || parsed.type === 'error') { - onError(parsed.message || 'stream error') - return - } - if (parsed.type === 'delta' && parsed.text) onDelta(parsed.text) - if (parsed.type === 'done') onDone(parsed.finish_reason) - } catch { - /* ignore malformed chunk */ + if (event === 'error' || parsed.type === 'error') { + finishError(parsed.message || 'stream error') + return true + } + if (parsed.type === 'delta' && parsed.text) onDelta(parsed.text) + if (parsed.type === 'done') { + finishDone(parsed.finish_reason) + return true + } + } catch { + /* ignore malformed chunk */ + } + return false + } + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const parts = buffer.split('\n\n') + buffer = parts.pop() ?? '' + for (const part of parts) { + if (handlePart(part)) return } } + // 沖出殘餘 buffer(最後一段可能沒有結尾的空行)。 + if (buffer.trim()) handlePart(buffer) + } catch (err) { + if (signal?.aborted) { + finishDone() + return + } + finishError(err instanceof Error ? err.message : '串流連線中斷') + return } + + // reader 正常結束但未收到明確的 done 事件:仍要 settle,避免卡死。 + finishDone() } export async function streamIslanderChat( @@ -208,16 +251,26 @@ export async function streamIslanderChat( onDelta: (text: string) => void, onDone: (finishReason?: string) => void, onError: (msg: string) => void, + signal?: AbortSignal, ) { const memberToken = storage.getAccessToken() const headers: Record = { 'Content-Type': 'application/json' } if (memberToken) headers.Authorization = `Bearer ${memberToken}` - const res = await fetch('/api/v1/ai/islander/chat/stream', { - method: 'POST', - headers, - body: JSON.stringify(body), - }) - await consumeAIEventStream(res, onDelta, onDone, onError) + try { + const res = await fetch('/api/v1/ai/islander/chat/stream', { + method: 'POST', + headers, + body: JSON.stringify(body), + signal, + }) + await consumeAIEventStream(res, onDelta, onDone, onError, signal) + } catch (err) { + if (signal?.aborted) { + onDone() + return + } + onError(err instanceof Error ? err.message : '無法連線島民 API') + } } diff --git a/web/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx similarity index 84% rename from web/src/auth/AuthContext.tsx rename to frontend/src/auth/AuthContext.tsx index 3007fc9..54690fb 100644 --- a/web/src/auth/AuthContext.tsx +++ b/frontend/src/auth/AuthContext.tsx @@ -64,7 +64,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { return } refreshMember() - .catch(() => storage.clearSession()) + .catch(() => { + storage.clearSession() + setUid('') + setMember(null) + }) .finally(() => setLoading(false)) }, [refreshMember]) @@ -106,6 +110,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, []) + // 當 client.ts 的 refresh 失敗(session 過期)時清空狀態,讓 ProtectedRoute 導回登入。 + useEffect(() => { + const onExpired = () => { + storage.clearSession() + setMember(null) + setUid('') + } + window.addEventListener('haixun:session-expired', onExpired) + return () => window.removeEventListener('haixun:session-expired', onExpired) + }, []) + const value = useMemo( () => ({ tenantId, @@ -117,7 +132,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { register, logout, refreshMember, - isAuthenticated: !!storage.getAccessToken(), + // 以 uid state 推導,確保登入/登出/過期時可反應式更新(不直接讀 storage)。 + isAuthenticated: !!uid, }), [tenantId, uid, member, loading, setTenantId, login, register, logout, refreshMember], ) diff --git a/web/src/components/AcIcon.tsx b/frontend/src/components/AcIcon.tsx similarity index 100% rename from web/src/components/AcIcon.tsx rename to frontend/src/components/AcIcon.tsx diff --git a/web/src/components/AccountAiSettings.tsx b/frontend/src/components/AccountAiSettings.tsx similarity index 57% rename from web/src/components/AccountAiSettings.tsx rename to frontend/src/components/AccountAiSettings.tsx index 9607a2e..00fa9e5 100644 --- a/web/src/components/AccountAiSettings.tsx +++ b/frontend/src/components/AccountAiSettings.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' +import { fetchAccountAiProviderModels } from '../api/ai' import { api, ApiError } from '../api/client' import { DEFAULT_AI_CREDENTIALS, @@ -9,6 +10,7 @@ import { PROVIDER_KEY_LABELS, PROVIDER_OPTIONS, PROVIDER_ORDER, + resolveProviderApiToken, type ProviderApiKeys, type ProviderId, } from '../lib/aiCredentials' @@ -20,6 +22,11 @@ type AccountAiSettingsProps = { compact?: boolean } +type ModelsMeta = { + loading: boolean + error: string +} + function aiSettingsPath(accountId: string) { return `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/ai-settings` } @@ -34,15 +41,81 @@ function parseConfigured(raw: Record | undefined): Record(null) const [keyInputs, setKeyInputs] = useState({}) const [configured, setConfigured] = useState>(() => getApiKeyStatus({})) + const [providerModels, setProviderModels] = useState>>({}) + const [modelsLoaded, setModelsLoaded] = useState>>({}) + const [modelsMeta, setModelsMeta] = useState>>({}) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState('') const [message, setMessage] = useState('') + const loadProviderModels = useCallback( + async (providerId: ProviderId, token?: string) => { + if (!accountId) return null + const overrideToken = token ?? resolveProviderApiToken(providerId, keyInputs) + if (!overrideToken && !configured[providerId]) return null + + setModelsMeta((prev) => ({ + ...prev, + [providerId]: { loading: true, error: '' }, + })) + try { + const data = await fetchAccountAiProviderModels( + accountId, + providerId, + overrideToken ?? undefined, + ) + const models = data.models ?? [] + setProviderModels((prev) => ({ ...prev, [providerId]: models })) + setModelsLoaded((prev) => ({ ...prev, [providerId]: true })) + setModelsMeta((prev) => ({ + ...prev, + [providerId]: { loading: false, error: data.error ?? '' }, + })) + if (models.length > 0) { + setSettings((prev) => { + if (!prev) return prev + const mainProvider = normalizeSupportedProvider(prev.provider) + const researchProv = normalizeSupportedProvider(prev.research_provider ?? mainProvider) + let next = prev + if (providerId === mainProvider && !models.includes(prev.model)) { + next = { ...next, model: models[0] } + } + if (providerId === researchProv) { + const currentResearch = prev.research_model ?? '' + if (!currentResearch || !models.includes(currentResearch)) { + next = { ...next, research_model: models[0] } + } + } + return next + }) + } + return data + } catch (e) { + setProviderModels((prev) => ({ ...prev, [providerId]: [] })) + setModelsLoaded((prev) => ({ ...prev, [providerId]: true })) + setModelsMeta((prev) => ({ + ...prev, + [providerId]: { + loading: false, + error: e instanceof ApiError ? e.message : '載入模型清單失敗', + }, + })) + return null + } + }, + [accountId, configured, keyInputs], + ) + const load = async () => { if (!accountId) return setLoading(true) @@ -71,6 +144,41 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps load().catch(() => undefined) }, [accountId]) + const provider = normalizeSupportedProvider(settings?.provider) || DEFAULT_AI_CREDENTIALS.provider + const researchProvider = normalizeSupportedProvider(settings?.research_provider ?? provider) + + useEffect(() => { + if (loading || !settings) return + void loadProviderModels(provider) + if (researchProvider !== provider) { + void loadProviderModels(researchProvider) + } + }, [loading, settings, provider, researchProvider, loadProviderModels]) + + useEffect(() => { + const timers: ReturnType[] = [] + for (const providerId of PROVIDER_ORDER) { + if (!resolveProviderApiToken(providerId, keyInputs)) continue + timers.push( + setTimeout(() => { + void loadProviderModels(providerId) + }, 500), + ) + } + return () => timers.forEach(clearTimeout) + }, [keyInputs, loadProviderModels]) + + const getModelsForProvider = (providerId: ProviderId) => { + if (!modelsLoaded[providerId]) return [] + return providerModels[providerId] ?? [] + } + + const primaryModels = getModelsForProvider(provider) + const researchModels = getModelsForProvider(researchProvider) + const primaryModelsMeta = modelsMeta[provider] + const researchModelsMeta = modelsMeta[researchProvider] + const currentProviderConfigured = configured[provider] + const save = async () => { if (!accountId || !settings) return setSaving(true) @@ -78,10 +186,10 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps setMessage('') try { const apiKeys: ProviderApiKeys = {} - for (const provider of PROVIDER_ORDER) { - const trimmed = keyInputs[provider]?.trim() + for (const providerId of PROVIDER_ORDER) { + const trimmed = keyInputs[providerId]?.trim() if (!trimmed || isMaskedKey(trimmed)) continue - apiKeys[provider] = trimmed + apiKeys[providerId] = trimmed } const normalized = normalizeProviderSettings({ provider: settings.provider, @@ -112,6 +220,9 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps setConfigured(parseConfigured(data.api_keys_configured)) setKeyInputs(data.api_keys ?? {}) setMessage('AI 設定已儲存') + for (const [providerId, token] of Object.entries(apiKeys) as [ProviderId, string][]) { + void loadProviderModels(providerId, token) + } } catch (e) { setError(e instanceof ApiError ? e.message : '儲存失敗') } finally { @@ -119,12 +230,6 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps } } - const provider = normalizeSupportedProvider(settings?.provider) || DEFAULT_AI_CREDENTIALS.provider - const researchProvider = normalizeSupportedProvider(settings?.research_provider ?? provider) - const providerOption = PROVIDER_OPTIONS.find((item) => item.value === provider) - const researchOption = PROVIDER_OPTIONS.find((item) => item.value === researchProvider) - const currentProviderConfigured = configured[provider] - if (!accountId) { return

請先從頂部選擇 Threads 經營帳號。

} @@ -150,16 +255,17 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps value={provider} onChange={(e) => { const next = e.target.value as ProviderId - const option = PROVIDER_OPTIONS.find((item) => item.value === next) + const models = getModelsForProvider(next) setSettings((prev) => prev ? { ...prev, provider: next, - model: option?.models[0] ?? prev.model, + model: pickModel(models, prev.model, DEFAULT_AI_CREDENTIALS.model), } : prev, ) + void loadProviderModels(next) }} > {PROVIDER_OPTIONS.map((item) => ( @@ -169,18 +275,36 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps ))} - + @@ -188,16 +312,21 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps value={researchProvider} onChange={(e) => { const next = e.target.value as ProviderId - const option = PROVIDER_OPTIONS.find((item) => item.value === next) + const models = getModelsForProvider(next) setSettings((prev) => prev ? { ...prev, research_provider: next, - research_model: option?.models[0] ?? prev.research_model, + research_model: pickModel( + models, + prev.research_model, + DEFAULT_AI_CREDENTIALS.research_model ?? DEFAULT_AI_CREDENTIALS.model, + ), } : prev, ) + void loadProviderModels(next) }} > {PROVIDER_OPTIONS.map((item) => ( @@ -207,20 +336,42 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps ))} - + diff --git a/web/src/components/AccountConnectionMode.tsx b/frontend/src/components/AccountConnectionMode.tsx similarity index 95% rename from web/src/components/AccountConnectionMode.tsx rename to frontend/src/components/AccountConnectionMode.tsx index 4001505..1ee127e 100644 --- a/web/src/components/AccountConnectionMode.tsx +++ b/frontend/src/components/AccountConnectionMode.tsx @@ -51,14 +51,20 @@ export function AccountConnectionMode({ accountId, connectionsPath }: AccountCon }, [accountId]) const saveSearchSourceMode = async (mode: SearchSourceMode) => { - if (!accountId) return + if (!accountId || busy) return + const apiMode = searchSourceModeToApi(mode) setBusy(true) setError('') setMessage('') + setConnection((prev) => + prev?.prefs + ? { ...prev, prefs: { ...prev.prefs, search_source_mode: apiMode } } + : prev, + ) try { const data = await api.patch( `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/connection`, - { search_source_mode: searchSourceModeToApi(mode) }, + { search_source_mode: apiMode }, { auth: true }, ) setConnection(data) @@ -66,6 +72,7 @@ export function AccountConnectionMode({ accountId, connectionsPath }: AccountCon await refreshOnboarding() } catch (e) { setError(e instanceof ApiError ? e.message : '儲存失敗') + await load() } finally { setBusy(false) } diff --git a/web/src/components/AccountDisplayNameSettings.tsx b/frontend/src/components/AccountDisplayNameSettings.tsx similarity index 100% rename from web/src/components/AccountDisplayNameSettings.tsx rename to frontend/src/components/AccountDisplayNameSettings.tsx diff --git a/web/src/components/AccountSwitcher.tsx b/frontend/src/components/AccountSwitcher.tsx similarity index 100% rename from web/src/components/AccountSwitcher.tsx rename to frontend/src/components/AccountSwitcher.tsx diff --git a/web/src/components/AdminRoute.tsx b/frontend/src/components/AdminRoute.tsx similarity index 100% rename from web/src/components/AdminRoute.tsx rename to frontend/src/components/AdminRoute.tsx diff --git a/web/src/components/AppBrandLink.tsx b/frontend/src/components/AppBrandLink.tsx similarity index 100% rename from web/src/components/AppBrandLink.tsx rename to frontend/src/components/AppBrandLink.tsx diff --git a/web/src/components/AppSidebar.tsx b/frontend/src/components/AppSidebar.tsx similarity index 100% rename from web/src/components/AppSidebar.tsx rename to frontend/src/components/AppSidebar.tsx diff --git a/web/src/components/AuthDecor.tsx b/frontend/src/components/AuthDecor.tsx similarity index 100% rename from web/src/components/AuthDecor.tsx rename to frontend/src/components/AuthDecor.tsx diff --git a/web/src/components/AuthShell.tsx b/frontend/src/components/AuthShell.tsx similarity index 100% rename from web/src/components/AuthShell.tsx rename to frontend/src/components/AuthShell.tsx diff --git a/web/src/components/BrandProductPicker.tsx b/frontend/src/components/BrandProductPicker.tsx similarity index 100% rename from web/src/components/BrandProductPicker.tsx rename to frontend/src/components/BrandProductPicker.tsx diff --git a/web/src/components/BrandProductsPanel.tsx b/frontend/src/components/BrandProductsPanel.tsx similarity index 100% rename from web/src/components/BrandProductsPanel.tsx rename to frontend/src/components/BrandProductsPanel.tsx diff --git a/web/src/components/BrandSwitcher.tsx b/frontend/src/components/BrandSwitcher.tsx similarity index 100% rename from web/src/components/BrandSwitcher.tsx rename to frontend/src/components/BrandSwitcher.tsx diff --git a/web/src/components/CopyFlowNav.tsx b/frontend/src/components/CopyFlowNav.tsx similarity index 100% rename from web/src/components/CopyFlowNav.tsx rename to frontend/src/components/CopyFlowNav.tsx diff --git a/web/src/components/CopyFlowPipeline.tsx b/frontend/src/components/CopyFlowPipeline.tsx similarity index 100% rename from web/src/components/CopyFlowPipeline.tsx rename to frontend/src/components/CopyFlowPipeline.tsx diff --git a/web/src/components/CopyMissionActiveJobsCard.tsx b/frontend/src/components/CopyMissionActiveJobsCard.tsx similarity index 100% rename from web/src/components/CopyMissionActiveJobsCard.tsx rename to frontend/src/components/CopyMissionActiveJobsCard.tsx diff --git a/web/src/components/CopyMissionAnalyzeJobPanel.tsx b/frontend/src/components/CopyMissionAnalyzeJobPanel.tsx similarity index 100% rename from web/src/components/CopyMissionAnalyzeJobPanel.tsx rename to frontend/src/components/CopyMissionAnalyzeJobPanel.tsx diff --git a/web/src/components/CopyMissionDiceButton.tsx b/frontend/src/components/CopyMissionDiceButton.tsx similarity index 100% rename from web/src/components/CopyMissionDiceButton.tsx rename to frontend/src/components/CopyMissionDiceButton.tsx diff --git a/web/src/components/CopyMissionDraftCard.tsx b/frontend/src/components/CopyMissionDraftCard.tsx similarity index 100% rename from web/src/components/CopyMissionDraftCard.tsx rename to frontend/src/components/CopyMissionDraftCard.tsx diff --git a/web/src/components/CopyMissionJobPanel.tsx b/frontend/src/components/CopyMissionJobPanel.tsx similarity index 100% rename from web/src/components/CopyMissionJobPanel.tsx rename to frontend/src/components/CopyMissionJobPanel.tsx diff --git a/web/src/components/CopyMissionResearchEditor.tsx b/frontend/src/components/CopyMissionResearchEditor.tsx similarity index 100% rename from web/src/components/CopyMissionResearchEditor.tsx rename to frontend/src/components/CopyMissionResearchEditor.tsx diff --git a/web/src/components/CopyMissionResearchOverview.tsx b/frontend/src/components/CopyMissionResearchOverview.tsx similarity index 100% rename from web/src/components/CopyMissionResearchOverview.tsx rename to frontend/src/components/CopyMissionResearchOverview.tsx diff --git a/web/src/components/CopyMissionScanSchedulePanel.tsx b/frontend/src/components/CopyMissionScanSchedulePanel.tsx similarity index 100% rename from web/src/components/CopyMissionScanSchedulePanel.tsx rename to frontend/src/components/CopyMissionScanSchedulePanel.tsx diff --git a/web/src/components/CopyMissionSwitcher.tsx b/frontend/src/components/CopyMissionSwitcher.tsx similarity index 100% rename from web/src/components/CopyMissionSwitcher.tsx rename to frontend/src/components/CopyMissionSwitcher.tsx diff --git a/web/src/components/CopyPersonaSwitcher.tsx b/frontend/src/components/CopyPersonaSwitcher.tsx similarity index 100% rename from web/src/components/CopyPersonaSwitcher.tsx rename to frontend/src/components/CopyPersonaSwitcher.tsx diff --git a/web/src/components/CopySuggestedTagsPicker.tsx b/frontend/src/components/CopySuggestedTagsPicker.tsx similarity index 100% rename from web/src/components/CopySuggestedTagsPicker.tsx rename to frontend/src/components/CopySuggestedTagsPicker.tsx diff --git a/web/src/components/CreatePlacementTopicDialog.tsx b/frontend/src/components/CreatePlacementTopicDialog.tsx similarity index 91% rename from web/src/components/CreatePlacementTopicDialog.tsx rename to frontend/src/components/CreatePlacementTopicDialog.tsx index d01e11a..4fa835d 100644 --- a/web/src/components/CreatePlacementTopicDialog.tsx +++ b/frontend/src/components/CreatePlacementTopicDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' import { api, ApiError } from '../api/client' import { getActiveBrandId } from '../lib/brandContext' @@ -6,6 +6,7 @@ import type { ExpandKnowledgeGraphData } from '../lib/knowledgeGraph' import { brandHasCatalogProducts } from '../lib/productCatalog' import { isCatalogBrand } from '../lib/productCatalog' import type { BrandData } from '../types/brand' +import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh' import type { CreatePlacementTopicData, PlacementTopicData } from '../types/placementTopic' import { BrandProductPicker } from './BrandProductPicker' import { Button, ErrorText, Field, Input, Textarea } from './ui' @@ -32,26 +33,43 @@ export function CreatePlacementTopicDialog({ const [error, setError] = useState('') const catalogBrands = useMemo(() => brands.filter(isCatalogBrand), [brands]) + const wasOpenRef = useRef(false) const selectedBrand = useMemo( () => catalogBrands.find((item) => item.id === brandId) ?? null, [brandId, catalogBrands], ) - useEffect(() => { - if (!open) return - const preferred = + const preferredBrandId = useMemo( + () => catalogBrands.find((item) => item.id === getActiveBrandId())?.id || catalogBrands.find((item) => brandHasCatalogProducts(item))?.id || catalogBrands[0]?.id || - '' - setBrandId(preferred) + '', + [catalogBrands], + ) + + useEffect(() => { + if (!open) { + wasOpenRef.current = false + return + } + const justOpened = !wasOpenRef.current + wasOpenRef.current = true + if (!justOpened) return + + setBrandId(preferredBrandId) setProductId(null) setTopicName('') setSeedQuery('') setBrief('') setError('') - }, [open, catalogBrands]) + }, [open, preferredBrandId]) + + useEffect(() => { + if (!open || brandId) return + if (preferredBrandId) setBrandId(preferredBrandId) + }, [open, brandId, preferredBrandId]) useEffect(() => { if (!open) return @@ -109,6 +127,7 @@ export function CreatePlacementTopicDialog({ { seed_query: seedQuery.trim(), regenerate_map: true }, { auth: true }, ) + requestJobMonitorRefresh() onCreated({ topic, job_id: job.job_id, diff --git a/web/src/components/DevToolsPanel.tsx b/frontend/src/components/DevToolsPanel.tsx similarity index 100% rename from web/src/components/DevToolsPanel.tsx rename to frontend/src/components/DevToolsPanel.tsx diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..cb9fd9f --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,50 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react' + +type ErrorBoundaryProps = { + children: ReactNode + fallback?: ReactNode +} + +type ErrorBoundaryState = { + hasError: boolean + message: string +} + +/** + * 攔截子樹的 render 例外,避免單一元件(例如渲染 AI 任意輸出的 IslanderMarkdown) + * 出錯就整頁白屏。 + */ +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { hasError: false, message: '' } + + static getDerivedStateFromError(error: unknown): ErrorBoundaryState { + return { hasError: true, message: error instanceof Error ? error.message : '發生未預期錯誤' } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('ErrorBoundary caught error:', error, info.componentStack) + } + + private handleReset = () => { + this.setState({ hasError: false, message: '' }) + } + + render() { + if (!this.state.hasError) return this.props.children + if (this.props.fallback) return this.props.fallback + + return ( +
+

+ 這個區塊發生問題,請重試或重新整理頁面。 +

+

+ {this.state.message} +

+ +
+ ) + } +} diff --git a/web/src/components/EvidenceList.tsx b/frontend/src/components/EvidenceList.tsx similarity index 100% rename from web/src/components/EvidenceList.tsx rename to frontend/src/components/EvidenceList.tsx diff --git a/web/src/components/ExpandGraphJobPanel.tsx b/frontend/src/components/ExpandGraphJobPanel.tsx similarity index 100% rename from web/src/components/ExpandGraphJobPanel.tsx rename to frontend/src/components/ExpandGraphJobPanel.tsx diff --git a/web/src/components/ExpandStrategyField.tsx b/frontend/src/components/ExpandStrategyField.tsx similarity index 100% rename from web/src/components/ExpandStrategyField.tsx rename to frontend/src/components/ExpandStrategyField.tsx diff --git a/web/src/components/ExtensionInstallCard.tsx b/frontend/src/components/ExtensionInstallCard.tsx similarity index 100% rename from web/src/components/ExtensionInstallCard.tsx rename to frontend/src/components/ExtensionInstallCard.tsx diff --git a/web/src/components/JobMonitor.tsx b/frontend/src/components/JobMonitor.tsx similarity index 83% rename from web/src/components/JobMonitor.tsx rename to frontend/src/components/JobMonitor.tsx index b32033c..25ae6b8 100644 --- a/web/src/components/JobMonitor.tsx +++ b/frontend/src/components/JobMonitor.tsx @@ -1,11 +1,18 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' import { api } from '../api/client' -import { isTerminalJobStatus, jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus' +import { isActiveJobStatus, isTerminalJobStatus, jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus' import { jobTemplateLabel } from '../lib/jobTemplate' -import { ANALYZE_COPY_MISSION_PIPELINE_STEPS, VIRAL_SCAN_PIPELINE_STEPS } from '../lib/copyFlow' +import { + ANALYZE_COPY_MISSION_PIPELINE_STEPS, + GENERATE_COPY_DRAFT_PIPELINE_STEPS, + GENERATE_COPY_MATRIX_PIPELINE_STEPS, + VIRAL_SCAN_PIPELINE_STEPS, +} from '../lib/copyFlow' import { EXPAND_GRAPH_PIPELINE_STEPS, PLACEMENT_SCAN_PIPELINE_STEPS } from '../lib/knowledgeGraph' +import { jobActivitySummary, jobOriginLinkLabel, jobOriginPath, jobStepDetailMessage } from '../lib/jobMonitorUi' import { setPlacementHandoff } from '../lib/islander/handoffStore' +import { JOB_MONITOR_REFRESH } from '../lib/jobMonitorRefresh' import { STYLE_8D_PIPELINE_STEPS } from '../lib/styleProfile' import type { JobData, Pagination } from '../types/api' import { Button, ProgressBar, StatusBadge } from './ui' @@ -61,36 +68,6 @@ function shortJobTitle(job: JobData) { return jobTemplateLabel(job.template_type) } -function phaseHint(job: JobData) { - if (job.template_type === 'expand-graph') { - return job.progress?.summary || '研究地圖產生中…' - } - if (job.template_type === 'placement-scan') { - return job.progress?.summary || '相關軌 + 近期軌雙軌 crawl' - } - if (job.template_type === 'scan-viral') { - return job.progress?.summary || '依關鍵字搜尋 Threads 爆款候選' - } - if (job.template_type === 'analyze-copy-mission') { - return job.progress?.summary || '產出研究地圖與預設搜尋標籤' - } - if (job.template_type === 'style-8d') { - switch (job.phase) { - case 'session': - return '確認 Chrome session 與 Worker 心跳' - case 'samples': - return '讀取對標帳號近期貼文樣本' - case 'style': - return '交給 AI 分析 D1-D8 風格策略' - case 'store': - return '寫回人設,讓後續產文套用' - default: - return '等待 8D worker 更新階段' - } - } - return job.phase ? `目前階段:${job.phase}` : '等待 Worker 回報階段' -} - function extractWorkflowHandoff(job: JobData, flow: string) { const raw = job.result?.handoff if (!raw || typeof raw !== 'object') return null @@ -147,6 +124,8 @@ function stepDefinitions(job: JobData) { if (job.template_type === 'placement-scan') return PLACEMENT_SCAN_PIPELINE_STEPS if (job.template_type === 'scan-viral') return VIRAL_SCAN_PIPELINE_STEPS if (job.template_type === 'analyze-copy-mission') return ANALYZE_COPY_MISSION_PIPELINE_STEPS + if (job.template_type === 'generate-copy-matrix') return GENERATE_COPY_MATRIX_PIPELINE_STEPS + if (job.template_type === 'generate-copy-draft') return GENERATE_COPY_DRAFT_PIPELINE_STEPS if (job.template_type === 'style-8d') return STYLE_8D_PIPELINE_STEPS const liveSteps = job.progress?.steps ?? [] if (liveSteps.length > 0) { @@ -176,20 +155,27 @@ function JobCard({ job, onCancel }: { job: JobData; onCancel: (id: string) => Pr const activeStep = steps.find((step) => step.status === 'running') const failedStep = steps.find((step) => step.status === 'failed') const canCancel = isActiveJob(job) + const originPath = jobOriginPath(job) + const title = shortJobTitle(job) + const activeDetail = jobStepDetailMessage(activeStep?.message) return (
- {shortJobTitle(job)} + {originPath ? ( + + {title} + + ) : ( + {title} + )} {jobStatusLabel(job.status)}
-

- Job {job.id.slice(0, 8)} · {job.progress?.percentage ?? 0}% -

+

{job.progress?.percentage ?? 0}%

{canCancel ? (
@@ -240,34 +224,14 @@ function JobCard({ job, onCancel }: { job: JobData; onCancel: (id: string) => Pr })} - {job.status === 'succeeded' && job.template_type === 'expand-graph' ? ( + {originPath ? ( - 前往研究頁勾選 tag → + {jobOriginLinkLabel(job)} ) : null} - {job.status === 'succeeded' && job.template_type === 'placement-scan' ? ( - - 前往獲客台 → - - ) : null} - - - 查看完整事件與原始資料 -
) } @@ -330,8 +294,8 @@ export function JobMonitor() { }) }, []) - const load = useCallback(async () => { - setLoading(true) + const load = useCallback(async (opts?: { silent?: boolean }) => { + if (!opts?.silent) setLoading(true) try { const data = await api.get<{ list: JobData[]; pagination: Pagination }>('/api/v1/jobs', { auth: true, @@ -343,15 +307,36 @@ export function JobMonitor() { } catch { // 登入初期或 gateway 重啟時保持安靜,避免干擾主要操作。 } finally { - setLoading(false) + if (!opts?.silent) setLoading(false) } }, []) + const hasActiveJob = jobs.some((job) => isActiveJobStatus(job.status)) + useEffect(() => { load().catch(() => undefined) - const timer = window.setInterval(() => load().catch(() => undefined), 2000) - return () => window.clearInterval(timer) - }, [load]) + // 有進行中任務時 2 秒刷新;閒置時 8 秒(原 20 秒太慢,新任務會 lag 很久才出現)。 + const intervalMs = hasActiveJob ? 2000 : 8000 + const timer = window.setInterval(() => { + if (document.visibilityState === 'hidden') return + load({ silent: true }).catch(() => undefined) + }, intervalMs) + + const onVisible = () => { + if (document.visibilityState === 'visible') load({ silent: true }).catch(() => undefined) + } + const onJobCreated = () => { + load({ silent: true }).catch(() => undefined) + } + document.addEventListener('visibilitychange', onVisible) + window.addEventListener(JOB_MONITOR_REFRESH, onJobCreated) + + return () => { + window.clearInterval(timer) + document.removeEventListener('visibilitychange', onVisible) + window.removeEventListener(JOB_MONITOR_REFRESH, onJobCreated) + } + }, [load, hasActiveJob]) const syncClampedPosition = useCallback(() => { setPosition((prev) => { @@ -426,7 +411,7 @@ export function JobMonitor() { const cancelJob = async (id: string) => { await api.post(`/api/v1/jobs/${id}/cancel`, { reason: 'ui cancel' }, { auth: true }) - await load() + await load({ silent: true }) } const dockTone = @@ -450,9 +435,7 @@ export function JobMonitor() {
{activeCount > 0 && !expanded && current ? (
-

- {current.progress?.summary || phaseHint(current)} -

+

{jobActivitySummary(current)}

) : null} diff --git a/web/src/components/KnowledgeGraphPanel.tsx b/frontend/src/components/KnowledgeGraphPanel.tsx similarity index 100% rename from web/src/components/KnowledgeGraphPanel.tsx rename to frontend/src/components/KnowledgeGraphPanel.tsx diff --git a/web/src/components/KnowledgeLayerRipple.tsx b/frontend/src/components/KnowledgeLayerRipple.tsx similarity index 100% rename from web/src/components/KnowledgeLayerRipple.tsx rename to frontend/src/components/KnowledgeLayerRipple.tsx diff --git a/web/src/components/KnowledgeOrbitDiagram.tsx b/frontend/src/components/KnowledgeOrbitDiagram.tsx similarity index 100% rename from web/src/components/KnowledgeOrbitDiagram.tsx rename to frontend/src/components/KnowledgeOrbitDiagram.tsx diff --git a/web/src/components/KnowledgeOverviewPanel.tsx b/frontend/src/components/KnowledgeOverviewPanel.tsx similarity index 100% rename from web/src/components/KnowledgeOverviewPanel.tsx rename to frontend/src/components/KnowledgeOverviewPanel.tsx diff --git a/web/src/components/Layout.tsx b/frontend/src/components/Layout.tsx similarity index 100% rename from web/src/components/Layout.tsx rename to frontend/src/components/Layout.tsx diff --git a/web/src/components/LegacyBrandRouteRedirect.tsx b/frontend/src/components/LegacyBrandRouteRedirect.tsx similarity index 100% rename from web/src/components/LegacyBrandRouteRedirect.tsx rename to frontend/src/components/LegacyBrandRouteRedirect.tsx diff --git a/web/src/components/MatrixEntryRoute.tsx b/frontend/src/components/MatrixEntryRoute.tsx similarity index 100% rename from web/src/components/MatrixEntryRoute.tsx rename to frontend/src/components/MatrixEntryRoute.tsx diff --git a/web/src/components/MemberMenu.tsx b/frontend/src/components/MemberMenu.tsx similarity index 100% rename from web/src/components/MemberMenu.tsx rename to frontend/src/components/MemberMenu.tsx diff --git a/web/src/components/MobileBottomNav.tsx b/frontend/src/components/MobileBottomNav.tsx similarity index 100% rename from web/src/components/MobileBottomNav.tsx rename to frontend/src/components/MobileBottomNav.tsx diff --git a/web/src/components/OnboardingBanner.tsx b/frontend/src/components/OnboardingBanner.tsx similarity index 100% rename from web/src/components/OnboardingBanner.tsx rename to frontend/src/components/OnboardingBanner.tsx diff --git a/web/src/components/OnboardingGuide.tsx b/frontend/src/components/OnboardingGuide.tsx similarity index 100% rename from web/src/components/OnboardingGuide.tsx rename to frontend/src/components/OnboardingGuide.tsx diff --git a/web/src/components/OnboardingRouteGuard.tsx b/frontend/src/components/OnboardingRouteGuard.tsx similarity index 82% rename from web/src/components/OnboardingRouteGuard.tsx rename to frontend/src/components/OnboardingRouteGuard.tsx index 1b1e433..d943d69 100644 --- a/web/src/components/OnboardingRouteGuard.tsx +++ b/frontend/src/components/OnboardingRouteGuard.tsx @@ -7,7 +7,9 @@ export function OnboardingRouteGuard({ children }: { children: ReactNode }) { const { pathname } = useLocation() const { loading, isComplete } = useOnboarding() - const redirectTo = !loading ? onboardingRedirectPath(pathname, isComplete) : null + if (loading) return children + + const redirectTo = onboardingRedirectPath(pathname, isComplete) if (redirectTo) return return children diff --git a/web/src/components/OutreachEntryRoute.tsx b/frontend/src/components/OutreachEntryRoute.tsx similarity index 100% rename from web/src/components/OutreachEntryRoute.tsx rename to frontend/src/components/OutreachEntryRoute.tsx diff --git a/web/src/components/PatrolKeywordsEditor.tsx b/frontend/src/components/PatrolKeywordsEditor.tsx similarity index 100% rename from web/src/components/PatrolKeywordsEditor.tsx rename to frontend/src/components/PatrolKeywordsEditor.tsx diff --git a/web/src/components/PatrolNodeTagsEditor.tsx b/frontend/src/components/PatrolNodeTagsEditor.tsx similarity index 100% rename from web/src/components/PatrolNodeTagsEditor.tsx rename to frontend/src/components/PatrolNodeTagsEditor.tsx diff --git a/web/src/components/PersonaIslandArt.tsx b/frontend/src/components/PersonaIslandArt.tsx similarity index 100% rename from web/src/components/PersonaIslandArt.tsx rename to frontend/src/components/PersonaIslandArt.tsx diff --git a/web/src/components/PersonaScanSchedulePanel.tsx b/frontend/src/components/PersonaScanSchedulePanel.tsx similarity index 100% rename from web/src/components/PersonaScanSchedulePanel.tsx rename to frontend/src/components/PersonaScanSchedulePanel.tsx diff --git a/web/src/components/PlacementFlowNav.tsx b/frontend/src/components/PlacementFlowNav.tsx similarity index 100% rename from web/src/components/PlacementFlowNav.tsx rename to frontend/src/components/PlacementFlowNav.tsx diff --git a/web/src/components/PlacementFlowPipeline.tsx b/frontend/src/components/PlacementFlowPipeline.tsx similarity index 100% rename from web/src/components/PlacementFlowPipeline.tsx rename to frontend/src/components/PlacementFlowPipeline.tsx diff --git a/web/src/components/PlacementResearchSettings.tsx b/frontend/src/components/PlacementResearchSettings.tsx similarity index 100% rename from web/src/components/PlacementResearchSettings.tsx rename to frontend/src/components/PlacementResearchSettings.tsx diff --git a/web/src/components/PlacementScanJobPanel.tsx b/frontend/src/components/PlacementScanJobPanel.tsx similarity index 100% rename from web/src/components/PlacementScanJobPanel.tsx rename to frontend/src/components/PlacementScanJobPanel.tsx diff --git a/web/src/components/ProductContextForm.tsx b/frontend/src/components/ProductContextForm.tsx similarity index 100% rename from web/src/components/ProductContextForm.tsx rename to frontend/src/components/ProductContextForm.tsx diff --git a/web/src/components/ProductItemForm.tsx b/frontend/src/components/ProductItemForm.tsx similarity index 100% rename from web/src/components/ProductItemForm.tsx rename to frontend/src/components/ProductItemForm.tsx diff --git a/web/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx similarity index 100% rename from web/src/components/ProtectedRoute.tsx rename to frontend/src/components/ProtectedRoute.tsx diff --git a/web/src/components/RegisterForm.tsx b/frontend/src/components/RegisterForm.tsx similarity index 100% rename from web/src/components/RegisterForm.tsx rename to frontend/src/components/RegisterForm.tsx diff --git a/web/src/components/ReplyPersonaPicker.tsx b/frontend/src/components/ReplyPersonaPicker.tsx similarity index 100% rename from web/src/components/ReplyPersonaPicker.tsx rename to frontend/src/components/ReplyPersonaPicker.tsx diff --git a/web/src/components/ResearchMapEditor.tsx b/frontend/src/components/ResearchMapEditor.tsx similarity index 100% rename from web/src/components/ResearchMapEditor.tsx rename to frontend/src/components/ResearchMapEditor.tsx diff --git a/web/src/components/ResearchMapOverview.tsx b/frontend/src/components/ResearchMapOverview.tsx similarity index 100% rename from web/src/components/ResearchMapOverview.tsx rename to frontend/src/components/ResearchMapOverview.tsx diff --git a/web/src/components/ResearchMapPanel.tsx b/frontend/src/components/ResearchMapPanel.tsx similarity index 100% rename from web/src/components/ResearchMapPanel.tsx rename to frontend/src/components/ResearchMapPanel.tsx diff --git a/web/src/components/ScheduleFrequencyPicker.tsx b/frontend/src/components/ScheduleFrequencyPicker.tsx similarity index 100% rename from web/src/components/ScheduleFrequencyPicker.tsx rename to frontend/src/components/ScheduleFrequencyPicker.tsx diff --git a/web/src/components/SourceLinkPreview.tsx b/frontend/src/components/SourceLinkPreview.tsx similarity index 100% rename from web/src/components/SourceLinkPreview.tsx rename to frontend/src/components/SourceLinkPreview.tsx diff --git a/web/src/components/StringListEditor.tsx b/frontend/src/components/StringListEditor.tsx similarity index 100% rename from web/src/components/StringListEditor.tsx rename to frontend/src/components/StringListEditor.tsx diff --git a/web/src/components/Style8DAnalysisMeta.tsx b/frontend/src/components/Style8DAnalysisMeta.tsx similarity index 100% rename from web/src/components/Style8DAnalysisMeta.tsx rename to frontend/src/components/Style8DAnalysisMeta.tsx diff --git a/web/src/components/Style8DDimensionEditor.tsx b/frontend/src/components/Style8DDimensionEditor.tsx similarity index 100% rename from web/src/components/Style8DDimensionEditor.tsx rename to frontend/src/components/Style8DDimensionEditor.tsx diff --git a/web/src/components/Style8DJobPanel.tsx b/frontend/src/components/Style8DJobPanel.tsx similarity index 100% rename from web/src/components/Style8DJobPanel.tsx rename to frontend/src/components/Style8DJobPanel.tsx diff --git a/web/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx similarity index 100% rename from web/src/components/ThemeToggle.tsx rename to frontend/src/components/ThemeToggle.tsx diff --git a/web/src/components/ThreadsAccountWorkspace.tsx b/frontend/src/components/ThreadsAccountWorkspace.tsx similarity index 100% rename from web/src/components/ThreadsAccountWorkspace.tsx rename to frontend/src/components/ThreadsAccountWorkspace.tsx diff --git a/web/src/components/ThreadsPostCharMeter.tsx b/frontend/src/components/ThreadsPostCharMeter.tsx similarity index 100% rename from web/src/components/ThreadsPostCharMeter.tsx rename to frontend/src/components/ThreadsPostCharMeter.tsx diff --git a/web/src/components/TopicSwitcher.tsx b/frontend/src/components/TopicSwitcher.tsx similarity index 100% rename from web/src/components/TopicSwitcher.tsx rename to frontend/src/components/TopicSwitcher.tsx diff --git a/web/src/components/VerifiedAuthorBadge.tsx b/frontend/src/components/VerifiedAuthorBadge.tsx similarity index 100% rename from web/src/components/VerifiedAuthorBadge.tsx rename to frontend/src/components/VerifiedAuthorBadge.tsx diff --git a/web/src/components/ViralScanJobPanel.tsx b/frontend/src/components/ViralScanJobPanel.tsx similarity index 100% rename from web/src/components/ViralScanJobPanel.tsx rename to frontend/src/components/ViralScanJobPanel.tsx diff --git a/web/src/components/islander/IslanderAvatar.tsx b/frontend/src/components/islander/IslanderAvatar.tsx similarity index 100% rename from web/src/components/islander/IslanderAvatar.tsx rename to frontend/src/components/islander/IslanderAvatar.tsx diff --git a/web/src/components/islander/IslanderChatPanel.tsx b/frontend/src/components/islander/IslanderChatPanel.tsx similarity index 100% rename from web/src/components/islander/IslanderChatPanel.tsx rename to frontend/src/components/islander/IslanderChatPanel.tsx diff --git a/web/src/components/islander/IslanderCompanion.tsx b/frontend/src/components/islander/IslanderCompanion.tsx similarity index 95% rename from web/src/components/islander/IslanderCompanion.tsx rename to frontend/src/components/islander/IslanderCompanion.tsx index 2ee8f84..7b559ea 100644 --- a/web/src/components/islander/IslanderCompanion.tsx +++ b/frontend/src/components/islander/IslanderCompanion.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { api, streamIslanderChat } from '../../api/client' import { @@ -47,6 +47,14 @@ export function IslanderCompanion() { const pageMeta = pageCtx?.runtimeMeta ?? null + // 取消尚在進行的 SSE 串流(送出新訊息或元件卸載時)。 + const streamAbortRef = useRef(null) + useEffect(() => { + return () => { + streamAbortRef.current?.abort() + } + }, []) + const setExpandedPersisted = useCallback((value: boolean | ((prev: boolean) => boolean)) => { setExpanded((prev) => { const next = typeof value === 'function' ? value(prev) : value @@ -89,6 +97,10 @@ export function IslanderCompanion() { setStreaming(true) setActing(false) + streamAbortRef.current?.abort() + const abortController = new AbortController() + streamAbortRef.current = abortController + const userMessage: IslanderChatMessage = { id: nextMessageId(), role: 'user', @@ -141,6 +153,7 @@ export function IslanderCompanion() { (delta) => onDelta(delta), () => resolve(), (message) => reject(new Error(message)), + abortController.signal, ) }) }, diff --git a/web/src/components/islander/IslanderMarkdown.tsx b/frontend/src/components/islander/IslanderMarkdown.tsx similarity index 92% rename from web/src/components/islander/IslanderMarkdown.tsx rename to frontend/src/components/islander/IslanderMarkdown.tsx index 6c418da..bf764fd 100644 --- a/web/src/components/islander/IslanderMarkdown.tsx +++ b/frontend/src/components/islander/IslanderMarkdown.tsx @@ -5,7 +5,8 @@ import { Link } from 'react-router-dom' const markdownComponents: Components = { a: ({ href, children }) => { - if (href?.startsWith('/')) { + // 僅單斜線開頭視為站內路徑;`//evil.com` 等 protocol-relative 連結走外部分支,避免 open redirect。 + if (href?.startsWith('/') && !href.startsWith('//')) { return ( {children} diff --git a/web/src/components/ui.tsx b/frontend/src/components/ui.tsx similarity index 100% rename from web/src/components/ui.tsx rename to frontend/src/components/ui.tsx diff --git a/web/src/hooks/useActiveBrand.ts b/frontend/src/hooks/useActiveBrand.ts similarity index 100% rename from web/src/hooks/useActiveBrand.ts rename to frontend/src/hooks/useActiveBrand.ts diff --git a/web/src/hooks/useActiveCopyPersona.ts b/frontend/src/hooks/useActiveCopyPersona.ts similarity index 100% rename from web/src/hooks/useActiveCopyPersona.ts rename to frontend/src/hooks/useActiveCopyPersona.ts diff --git a/web/src/hooks/useActiveTopic.ts b/frontend/src/hooks/useActiveTopic.ts similarity index 100% rename from web/src/hooks/useActiveTopic.ts rename to frontend/src/hooks/useActiveTopic.ts diff --git a/web/src/hooks/useIslanderUnlock.ts b/frontend/src/hooks/useIslanderUnlock.ts similarity index 100% rename from web/src/hooks/useIslanderUnlock.ts rename to frontend/src/hooks/useIslanderUnlock.ts diff --git a/web/src/index.css b/frontend/src/index.css similarity index 100% rename from web/src/index.css rename to frontend/src/index.css diff --git a/web/src/lib/acAssets.ts b/frontend/src/lib/acAssets.ts similarity index 100% rename from web/src/lib/acAssets.ts rename to frontend/src/lib/acAssets.ts diff --git a/web/src/lib/aiCredentials.ts b/frontend/src/lib/aiCredentials.ts similarity index 90% rename from web/src/lib/aiCredentials.ts rename to frontend/src/lib/aiCredentials.ts index bf3694b..28fa018 100644 --- a/web/src/lib/aiCredentials.ts +++ b/frontend/src/lib/aiCredentials.ts @@ -64,6 +64,24 @@ function modelsForProvider(provider: ProviderId): readonly string[] { return PROVIDER_OPTIONS.find((item) => item.value === provider)?.models ?? [] } +export function fallbackModelsForProvider(provider: ProviderId, currentModel?: string): string[] { + const staticModels = [...modelsForProvider(provider)] + const trimmed = currentModel?.trim() + if (trimmed && !staticModels.includes(trimmed)) { + staticModels.unshift(trimmed) + } + return staticModels +} + +export function resolveProviderApiToken( + providerId: ProviderId, + keyInputs: ProviderApiKeys, +): string | null { + const value = keyInputs[providerId]?.trim() + if (!value || isMaskedKey(value)) return null + return value +} + function normalizeModel(provider: ProviderId, model: string | undefined, fallback: string): string { const models = modelsForProvider(provider) if (model && models.includes(model)) return model diff --git a/web/src/lib/brandContext.ts b/frontend/src/lib/brandContext.ts similarity index 100% rename from web/src/lib/brandContext.ts rename to frontend/src/lib/brandContext.ts diff --git a/web/src/lib/connectionMode.ts b/frontend/src/lib/connectionMode.ts similarity index 100% rename from web/src/lib/connectionMode.ts rename to frontend/src/lib/connectionMode.ts diff --git a/web/src/lib/copyFlow.ts b/frontend/src/lib/copyFlow.ts similarity index 100% rename from web/src/lib/copyFlow.ts rename to frontend/src/lib/copyFlow.ts diff --git a/web/src/lib/copyMissionJobs.ts b/frontend/src/lib/copyMissionJobs.ts similarity index 100% rename from web/src/lib/copyMissionJobs.ts rename to frontend/src/lib/copyMissionJobs.ts diff --git a/web/src/lib/easterEggCatalog.ts b/frontend/src/lib/easterEggCatalog.ts similarity index 100% rename from web/src/lib/easterEggCatalog.ts rename to frontend/src/lib/easterEggCatalog.ts diff --git a/web/src/lib/extensionInstall.ts b/frontend/src/lib/extensionInstall.ts similarity index 100% rename from web/src/lib/extensionInstall.ts rename to frontend/src/lib/extensionInstall.ts diff --git a/web/src/lib/extensionSync.ts b/frontend/src/lib/extensionSync.ts similarity index 88% rename from web/src/lib/extensionSync.ts rename to frontend/src/lib/extensionSync.ts index d241c7c..51329a8 100644 --- a/web/src/lib/extensionSync.ts +++ b/frontend/src/lib/extensionSync.ts @@ -28,8 +28,11 @@ export function normalizeExtensionSyncResult(raw: ExtensionSyncResult): Extensio } } +// 內容腳本注入在本頁,與本頁同 origin;限定 targetOrigin 避免把訊息(含 JWT)廣播給其他來源。 +const SAME_ORIGIN = window.location.origin + export function pingExtensionBridge() { - window.postMessage({ type: 'HAIXUN_PING_EXTENSION' }, '*') + window.postMessage({ type: 'HAIXUN_PING_EXTENSION' }, SAME_ORIGIN) } export function waitForExtensionBridge(timeoutMs = 8000): Promise { @@ -39,6 +42,7 @@ export function waitForExtensionBridge(timeoutMs = 8000): Promise { const started = Date.now() const onMessage = (event: MessageEvent) => { + if (event.origin !== SAME_ORIGIN) return if (event.source !== window) return if (event.data?.type !== 'HAIXUN_EXTENSION_READY') return cleanup() @@ -84,6 +88,7 @@ export function requestExtensionSync(input: { }, 30000) function onMessage(event: MessageEvent) { + if (event.origin !== SAME_ORIGIN) return if (event.source !== window) return if (event.data?.type !== 'HAIXUN_THREADS_SYNC_RESULT') return window.clearTimeout(timeout) @@ -100,7 +105,7 @@ export function requestExtensionSync(input: { accessToken: input.accessToken, apiVersion: 'go-v1', }, - '*', + SAME_ORIGIN, ) }) } \ No newline at end of file diff --git a/web/src/lib/formatDate.ts b/frontend/src/lib/formatDate.ts similarity index 100% rename from web/src/lib/formatDate.ts rename to frontend/src/lib/formatDate.ts diff --git a/web/src/lib/islander/IslanderPageContext.tsx b/frontend/src/lib/islander/IslanderPageContext.tsx similarity index 100% rename from web/src/lib/islander/IslanderPageContext.tsx rename to frontend/src/lib/islander/IslanderPageContext.tsx diff --git a/web/src/lib/islander/actionExecutor.ts b/frontend/src/lib/islander/actionExecutor.ts similarity index 91% rename from web/src/lib/islander/actionExecutor.ts rename to frontend/src/lib/islander/actionExecutor.ts index 618fee5..2fbe37f 100644 --- a/web/src/lib/islander/actionExecutor.ts +++ b/frontend/src/lib/islander/actionExecutor.ts @@ -189,6 +189,18 @@ type ExecuteOptions = { snapshot?: PageSnapshot } +function isDangerousAction(type: string): boolean { + return (ISLANDER_CONFIG.dangerousActionTypes as readonly string[]).includes(type) +} + +// 對危險動作要求真人確認;AI 自填的 confirm 不足以放行。 +function confirmDangerousAction(type: string): boolean { + if (typeof window === 'undefined' || typeof window.confirm !== 'function') return false + return window.confirm( + `島民想替你執行「${type}」,這會實際送出 / 發布或啟動背景任務。確定要執行嗎?`, + ) +} + export async function executeIslanderActions(opts: ExecuteOptions): Promise<{ results: IslanderActionResult[] snapshotText: string @@ -197,6 +209,10 @@ export async function executeIslanderActions(opts: ExecuteOptions): Promise<{ const results: IslanderActionResult[] = [] for (const action of opts.actions) { + if (isDangerousAction(action.type) && !confirmDangerousAction(action.type)) { + results.push({ action, ok: false, detail: '使用者未確認,已略過此操作' }) + break + } const result = await runAction(action, opts.navigate, snapshot) results.push(result) if (!result.ok && action.type !== 'wait') break diff --git a/web/src/lib/islander/buildIslanderContext.ts b/frontend/src/lib/islander/buildIslanderContext.ts similarity index 100% rename from web/src/lib/islander/buildIslanderContext.ts rename to frontend/src/lib/islander/buildIslanderContext.ts diff --git a/web/src/lib/islander/config.ts b/frontend/src/lib/islander/config.ts similarity index 70% rename from web/src/lib/islander/config.ts rename to frontend/src/lib/islander/config.ts index cd9262e..29bcb8c 100644 --- a/web/src/lib/islander/config.ts +++ b/frontend/src/lib/islander/config.ts @@ -38,6 +38,18 @@ export const ISLANDER_CONFIG = { `[data-islander-label]`, ], blockedClickPatterns: [/登出/i, /logout/i], + // 這些 action 會實際對外發布、啟動背景/付費任務或寫入後端, + // 必須由真人確認(window.confirm),不能只靠 AI 在 JSON 自填 confirm。 + // 避免被海巡抓回的不可信貼文做 prompt injection 觸發自動發文。 + dangerousActionTypes: [ + 'publishOutreach', + 'publishCopyDraft', + 'startViralScan', + 'startCopyMissionAnalyze', + 'startCopyMissionScan', + 'generateCopyMatrix', + 'generateCopyDraft', + ] as string[], defaultSuggestions: [] as string[], highlightClass: 'ac-islander-target-highlight', highlightDurationMs: 1800, diff --git a/web/src/lib/islander/copyActions.ts b/frontend/src/lib/islander/copyActions.ts similarity index 100% rename from web/src/lib/islander/copyActions.ts rename to frontend/src/lib/islander/copyActions.ts diff --git a/web/src/lib/islander/handoffStore.ts b/frontend/src/lib/islander/handoffStore.ts similarity index 100% rename from web/src/lib/islander/handoffStore.ts rename to frontend/src/lib/islander/handoffStore.ts diff --git a/web/src/lib/islander/index.ts b/frontend/src/lib/islander/index.ts similarity index 100% rename from web/src/lib/islander/index.ts rename to frontend/src/lib/islander/index.ts diff --git a/web/src/lib/islander/islanderActions.ts b/frontend/src/lib/islander/islanderActions.ts similarity index 100% rename from web/src/lib/islander/islanderActions.ts rename to frontend/src/lib/islander/islanderActions.ts diff --git a/web/src/lib/islander/islanderAgent.ts b/frontend/src/lib/islander/islanderAgent.ts similarity index 100% rename from web/src/lib/islander/islanderAgent.ts rename to frontend/src/lib/islander/islanderAgent.ts diff --git a/web/src/lib/islander/outreachActions.ts b/frontend/src/lib/islander/outreachActions.ts similarity index 100% rename from web/src/lib/islander/outreachActions.ts rename to frontend/src/lib/islander/outreachActions.ts diff --git a/web/src/lib/islander/pageContextIntent.ts b/frontend/src/lib/islander/pageContextIntent.ts similarity index 100% rename from web/src/lib/islander/pageContextIntent.ts rename to frontend/src/lib/islander/pageContextIntent.ts diff --git a/web/src/lib/islander/pageRegistry.ts b/frontend/src/lib/islander/pageRegistry.ts similarity index 100% rename from web/src/lib/islander/pageRegistry.ts rename to frontend/src/lib/islander/pageRegistry.ts diff --git a/web/src/lib/islander/pageSnapshot.ts b/frontend/src/lib/islander/pageSnapshot.ts similarity index 100% rename from web/src/lib/islander/pageSnapshot.ts rename to frontend/src/lib/islander/pageSnapshot.ts diff --git a/web/src/lib/islander/researchActions.ts b/frontend/src/lib/islander/researchActions.ts similarity index 96% rename from web/src/lib/islander/researchActions.ts rename to frontend/src/lib/islander/researchActions.ts index 0363156..bd66893 100644 --- a/web/src/lib/islander/researchActions.ts +++ b/frontend/src/lib/islander/researchActions.ts @@ -1,4 +1,5 @@ import { api } from '../../api/client' +import { requestJobMonitorRefresh } from '../jobMonitorRefresh' import { registerIslanderActionHandler } from './actionExecutor' import type { IslanderAction, IslanderExecutorContext } from './types' import type { KnowledgeGraphData, KnowledgeGraphNode } from '../knowledgeGraph' @@ -46,6 +47,7 @@ export function ensureResearchActionHandlers() { { seed_query: seed, supplemental: !!typed.supplemental }, { auth: true }, ) + requestJobMonitorRefresh() handlers.onExpandStarted(data.job_id, data.message) return { action, @@ -90,6 +92,7 @@ export function ensureResearchActionHandlers() { { dual_track: true, graph_id: saved.id }, { auth: true }, ) + requestJobMonitorRefresh() handlers.onScanStarted(data.job_id, data.message) window.dispatchEvent( new CustomEvent(RESEARCH_SCAN_EVENT, { diff --git a/web/src/lib/islander/siteGuide.ts b/frontend/src/lib/islander/siteGuide.ts similarity index 100% rename from web/src/lib/islander/siteGuide.ts rename to frontend/src/lib/islander/siteGuide.ts diff --git a/web/src/lib/islander/types.ts b/frontend/src/lib/islander/types.ts similarity index 100% rename from web/src/lib/islander/types.ts rename to frontend/src/lib/islander/types.ts diff --git a/web/src/lib/islander/unlock.ts b/frontend/src/lib/islander/unlock.ts similarity index 100% rename from web/src/lib/islander/unlock.ts rename to frontend/src/lib/islander/unlock.ts diff --git a/web/src/lib/islander/useIslanderPosition.ts b/frontend/src/lib/islander/useIslanderPosition.ts similarity index 100% rename from web/src/lib/islander/useIslanderPosition.ts rename to frontend/src/lib/islander/useIslanderPosition.ts diff --git a/frontend/src/lib/jobMonitorRefresh.ts b/frontend/src/lib/jobMonitorRefresh.ts new file mode 100644 index 0000000..b9ff367 --- /dev/null +++ b/frontend/src/lib/jobMonitorRefresh.ts @@ -0,0 +1,6 @@ +export const JOB_MONITOR_REFRESH = 'haixun:job-monitor-refresh' + +export function requestJobMonitorRefresh() { + if (typeof window === 'undefined') return + window.dispatchEvent(new Event(JOB_MONITOR_REFRESH)) +} \ No newline at end of file diff --git a/frontend/src/lib/jobMonitorUi.ts b/frontend/src/lib/jobMonitorUi.ts new file mode 100644 index 0000000..6eb8593 --- /dev/null +++ b/frontend/src/lib/jobMonitorUi.ts @@ -0,0 +1,206 @@ +import type { JobData } from '../types/api' +import { + ANALYZE_COPY_MISSION_PIPELINE_STEPS, + GENERATE_COPY_DRAFT_PIPELINE_STEPS, + GENERATE_COPY_MATRIX_PIPELINE_STEPS, + VIRAL_SCAN_PIPELINE_STEPS, + copyMissionDetailPath, +} from './copyFlow' +import { jobErrorMessage, isTerminalJobStatus } from './jobStatus' +import { jobTemplateLabel } from './jobTemplate' +import { EXPAND_GRAPH_PIPELINE_STEPS, PLACEMENT_SCAN_PIPELINE_STEPS } from './knowledgeGraph' +import { STYLE_8D_PIPELINE_STEPS } from './styleProfile' +import { placementFlowPath } from './placementFlow' +import { topicResearchMapPath } from './placementTopics' + +const STEP_TITLE_BY_ID = new Map( + [ + ...EXPAND_GRAPH_PIPELINE_STEPS, + ...PLACEMENT_SCAN_PIPELINE_STEPS, + ...VIRAL_SCAN_PIPELINE_STEPS, + ...ANALYZE_COPY_MISSION_PIPELINE_STEPS, + ...GENERATE_COPY_MATRIX_PIPELINE_STEPS, + ...GENERATE_COPY_DRAFT_PIPELINE_STEPS, + ...STYLE_8D_PIPELINE_STEPS, + { id: 'prepare', title: '準備' }, + { id: 'execute', title: '執行' }, + { id: 'finalize', title: '收尾' }, + ].map((step) => [step.id, step.title]), +) + +const PHASE_LABEL: Record = { + expand: '產生研究地圖', + crawl: '雙軌海巡', + viral_crawl: '爆款海巡', + copy_mission_map: '研究地圖', + copy_matrix_generate: '內容矩陣', + copy_draft_generate: '深仿寫', + session: '確認連線', + samples: '抓取樣本', + style: 'AI 8D 分析', + store: '寫入人設', + prepare: '準備', + execute: '執行', + finalize: '收尾', +} + +const TERMINAL_ACTIVITY: Record = { + 'expand-graph': '研究地圖已產生完成', + 'placement-scan': '雙軌海巡已完成', + 'scan-viral': '爆款掃描已完成', + 'analyze-copy-mission': '拷貝研究地圖已產生完成', + 'generate-copy-matrix': '內容矩陣已產生完成', + 'generate-copy-draft': '仿寫草稿已產生完成', + 'style-8d': '8D 風格分析已完成', +} + +function stepTitle(stepId: string): string { + return STEP_TITLE_BY_ID.get(stepId) ?? PHASE_LABEL[stepId] ?? stepId +} + +function hasCjk(text: string): boolean { + return /[\u3400-\u9fff]/.test(text) +} + +function translateGenericSummary(summary: string, job: JobData): string | null { + const trimmed = summary.trim() + if (!trimmed) return null + + const waiting = trimmed.match(/^waiting for worker$/i) + if (waiting) return '等待 Worker 接手…' + + const running = trimmed.match(/^running step (.+)$/i) + if (running) return `正在執行:${stepTitle(running[1])}` + + const completed = trimmed.match(/^completed step (.+)$/i) + if (completed) { + if (isTerminalJobStatus(job.status)) { + return TERMINAL_ACTIVITY[job.template_type] ?? `${stepTitle(completed[1])}已完成` + } + return `已完成:${stepTitle(completed[1])}` + } + + if (/^step failed$/i.test(trimmed)) return '步驟失敗' + + return hasCjk(trimmed) ? trimmed : null +} + +function activePhaseHint(job: JobData): string { + if (job.template_type === 'expand-graph') { + return '研究地圖產生中…' + } + if (job.template_type === 'placement-scan') { + return '雙軌海巡進行中…' + } + if (job.template_type === 'scan-viral') { + return '依關鍵字搜尋 Threads 爆款候選…' + } + if (job.template_type === 'analyze-copy-mission') { + return '產出研究地圖與預設搜尋標籤…' + } + if (job.template_type === 'generate-copy-matrix') { + return '依爆款樣本產出內容矩陣…' + } + if (job.template_type === 'generate-copy-draft') { + return '分析爆款結構並產出仿寫草稿…' + } + if (job.template_type === 'style-8d') { + switch (job.phase) { + case 'session': + return '確認 Chrome session 與 Worker 心跳…' + case 'samples': + return '讀取對標帳號近期貼文樣本…' + case 'style': + return '交給 AI 分析 D1–D8 風格策略…' + case 'store': + return '寫回人設,讓後續產文套用…' + default: + return '8D 風格分析進行中…' + } + } + if (job.phase) { + const label = PHASE_LABEL[job.phase] + return label ? `目前階段:${label}` : '任務執行中…' + } + return '等待 Worker 回報進度…' +} + +/** 任務觀察站「現在在做」— 終態會改為完成/失敗等繁中說明。 */ +export function jobActivitySummary(job: JobData): string { + if (job.status === 'cancel_requested') return '正在取消任務…' + if (job.status === 'cancelled') return '任務已取消' + if (job.status === 'expired') return '任務已過期(執行逾時)' + if (job.status === 'failed') { + return jobErrorMessage(job) ?? `${jobTemplateLabel(job.template_type)}失敗` + } + if (job.status === 'succeeded') { + return TERMINAL_ACTIVITY[job.template_type] ?? `${jobTemplateLabel(job.template_type)}已完成` + } + + const steps = job.progress?.steps ?? [] + const activeStep = steps.find((step) => step.status === 'running') + if (activeStep?.message && hasCjk(activeStep.message)) { + return activeStep.message + } + + const summary = job.progress?.summary?.trim() + if (summary) { + const translated = translateGenericSummary(summary, job) + if (translated) return translated + } + + return activePhaseHint(job) +} + +export function jobStepDetailMessage(message?: string): string | null { + const trimmed = message?.trim() + if (!trimmed) return null + if (/^(running|done)$/i.test(trimmed)) return null + if (hasCjk(trimmed)) return trimmed + if (/^running$/i.test(trimmed)) return null + return trimmed +} + +/** 回到發起任務的頁面(不顯示 job id)。 */ +export function jobOriginPath(job: JobData): string | null { + const id = job.scope_id?.trim() + if (!id) return null + + switch (job.template_type) { + case 'expand-graph': + if (job.scope === 'placement_topic') return topicResearchMapPath(id) + return placementFlowPath('/research', id, 'brand') + case 'placement-scan': + if (job.scope === 'placement_topic') return placementFlowPath('/outreach', id, 'topic') + return placementFlowPath('/outreach', id, 'brand') + case 'analyze-copy-mission': + return copyMissionDetailPath(id, '#copy-research') + case 'scan-viral': + case 'generate-copy-matrix': + case 'generate-copy-draft': + if (job.scope === 'copy_mission') return copyMissionDetailPath(id, '#copy-output') + return `/matrix/missions/${encodeURIComponent(id)}` + case 'style-8d': + return `/personas/${encodeURIComponent(id)}` + default: + return null + } +} + +export function jobOriginLinkLabel(job: JobData): string { + switch (job.template_type) { + case 'expand-graph': + return job.status === 'succeeded' ? '回到研究地圖勾選 tag →' : '回到研究地圖 →' + case 'placement-scan': + return job.status === 'succeeded' ? '回到獲客台 →' : '回到海巡頁 →' + case 'analyze-copy-mission': + case 'scan-viral': + case 'generate-copy-matrix': + case 'generate-copy-draft': + return '回到拷貝任務 →' + case 'style-8d': + return '回到人設頁 →' + default: + return '回到任務頁 →' + } +} \ No newline at end of file diff --git a/web/src/lib/jobStatus.ts b/frontend/src/lib/jobStatus.ts similarity index 100% rename from web/src/lib/jobStatus.ts rename to frontend/src/lib/jobStatus.ts diff --git a/web/src/lib/jobTemplate.ts b/frontend/src/lib/jobTemplate.ts similarity index 100% rename from web/src/lib/jobTemplate.ts rename to frontend/src/lib/jobTemplate.ts diff --git a/web/src/lib/knowledgeGraph.ts b/frontend/src/lib/knowledgeGraph.ts similarity index 100% rename from web/src/lib/knowledgeGraph.ts rename to frontend/src/lib/knowledgeGraph.ts diff --git a/web/src/lib/memberRole.ts b/frontend/src/lib/memberRole.ts similarity index 100% rename from web/src/lib/memberRole.ts rename to frontend/src/lib/memberRole.ts diff --git a/web/src/lib/onboarding.ts b/frontend/src/lib/onboarding.ts similarity index 100% rename from web/src/lib/onboarding.ts rename to frontend/src/lib/onboarding.ts diff --git a/web/src/lib/pageJobScope.ts b/frontend/src/lib/pageJobScope.ts similarity index 100% rename from web/src/lib/pageJobScope.ts rename to frontend/src/lib/pageJobScope.ts diff --git a/web/src/lib/placementFlow.ts b/frontend/src/lib/placementFlow.ts similarity index 100% rename from web/src/lib/placementFlow.ts rename to frontend/src/lib/placementFlow.ts diff --git a/web/src/lib/placementResearch.ts b/frontend/src/lib/placementResearch.ts similarity index 100% rename from web/src/lib/placementResearch.ts rename to frontend/src/lib/placementResearch.ts diff --git a/web/src/lib/placementTopics.ts b/frontend/src/lib/placementTopics.ts similarity index 100% rename from web/src/lib/placementTopics.ts rename to frontend/src/lib/placementTopics.ts diff --git a/web/src/lib/productCatalog.ts b/frontend/src/lib/productCatalog.ts similarity index 100% rename from web/src/lib/productCatalog.ts rename to frontend/src/lib/productCatalog.ts diff --git a/web/src/lib/productContext.ts b/frontend/src/lib/productContext.ts similarity index 100% rename from web/src/lib/productContext.ts rename to frontend/src/lib/productContext.ts diff --git a/web/src/lib/productMatch.ts b/frontend/src/lib/productMatch.ts similarity index 100% rename from web/src/lib/productMatch.ts rename to frontend/src/lib/productMatch.ts diff --git a/web/src/lib/publishPersona.ts b/frontend/src/lib/publishPersona.ts similarity index 100% rename from web/src/lib/publishPersona.ts rename to frontend/src/lib/publishPersona.ts diff --git a/web/src/lib/scheduleCatalog.ts b/frontend/src/lib/scheduleCatalog.ts similarity index 100% rename from web/src/lib/scheduleCatalog.ts rename to frontend/src/lib/scheduleCatalog.ts diff --git a/web/src/lib/scheduleCron.ts b/frontend/src/lib/scheduleCron.ts similarity index 100% rename from web/src/lib/scheduleCron.ts rename to frontend/src/lib/scheduleCron.ts diff --git a/web/src/lib/searchSourceMode.ts b/frontend/src/lib/searchSourceMode.ts similarity index 97% rename from web/src/lib/searchSourceMode.ts rename to frontend/src/lib/searchSourceMode.ts index 64fad46..4a04340 100644 --- a/web/src/lib/searchSourceMode.ts +++ b/frontend/src/lib/searchSourceMode.ts @@ -57,6 +57,7 @@ export function normalizeSearchSourceMode(mode: string | undefined): SearchSourc } if (mode === 'threads_brave') return 'mixed' if (mode === 'brave_crawler') return 'threads_crawler' + if (mode === 'browser') return 'threads_crawler' return 'mixed' } diff --git a/web/src/lib/sourcePreview.ts b/frontend/src/lib/sourcePreview.ts similarity index 100% rename from web/src/lib/sourcePreview.ts rename to frontend/src/lib/sourcePreview.ts diff --git a/web/src/lib/storage.ts b/frontend/src/lib/storage.ts similarity index 88% rename from web/src/lib/storage.ts rename to frontend/src/lib/storage.ts index a86998d..b1d96dd 100644 --- a/web/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -25,5 +25,8 @@ export const storage = { localStorage.removeItem(KEYS.refreshToken) localStorage.removeItem(KEYS.uid) localStorage.removeItem(KEYS.activeThreadsAccountId) + // 高敏感的 AI provider key 不可在登出後殘留(共用裝置)。 + localStorage.removeItem(KEYS.aiProviderToken) + localStorage.removeItem(KEYS.tenantId) }, } \ No newline at end of file diff --git a/web/src/lib/styleEvidence.ts b/frontend/src/lib/styleEvidence.ts similarity index 100% rename from web/src/lib/styleEvidence.ts rename to frontend/src/lib/styleEvidence.ts diff --git a/web/src/lib/styleProfile.ts b/frontend/src/lib/styleProfile.ts similarity index 100% rename from web/src/lib/styleProfile.ts rename to frontend/src/lib/styleProfile.ts diff --git a/web/src/lib/threadsAccount.ts b/frontend/src/lib/threadsAccount.ts similarity index 100% rename from web/src/lib/threadsAccount.ts rename to frontend/src/lib/threadsAccount.ts diff --git a/web/src/lib/threadsLinks.ts b/frontend/src/lib/threadsLinks.ts similarity index 100% rename from web/src/lib/threadsLinks.ts rename to frontend/src/lib/threadsLinks.ts diff --git a/web/src/lib/threadsPostLimits.ts b/frontend/src/lib/threadsPostLimits.ts similarity index 100% rename from web/src/lib/threadsPostLimits.ts rename to frontend/src/lib/threadsPostLimits.ts diff --git a/web/src/lib/viralSignals.ts b/frontend/src/lib/viralSignals.ts similarity index 100% rename from web/src/lib/viralSignals.ts rename to frontend/src/lib/viralSignals.ts diff --git a/web/src/main.tsx b/frontend/src/main.tsx similarity index 100% rename from web/src/main.tsx rename to frontend/src/main.tsx diff --git a/web/src/onboarding/OnboardingContext.tsx b/frontend/src/onboarding/OnboardingContext.tsx similarity index 88% rename from web/src/onboarding/OnboardingContext.tsx rename to frontend/src/onboarding/OnboardingContext.tsx index 44307c0..c69a4fb 100644 --- a/web/src/onboarding/OnboardingContext.tsx +++ b/frontend/src/onboarding/OnboardingContext.tsx @@ -4,6 +4,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, type ReactNode, } from 'react' @@ -30,9 +31,11 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { const [personaCount, setPersonaCount] = useState(0) const [connectionLoading, setConnectionLoading] = useState(false) const [connectionReady, setConnectionReady] = useState(false) + const hasLoadedRef = useRef(false) const refresh = useCallback(async () => { - setPersonasLoading(true) + const silent = hasLoadedRef.current + if (!silent) setPersonasLoading(true) let nextPersonaCount = 0 try { const personas = await api.get('/api/v1/personas', { auth: true }) @@ -41,12 +44,13 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { } catch { setPersonaCount(0) } finally { - setPersonasLoading(false) + if (!silent) setPersonasLoading(false) } if (!activeAccountId) { setConnectionReady(false) - setConnectionLoading(false) + if (!silent) setConnectionLoading(false) + hasLoadedRef.current = true return buildOnboardingSnapshot({ accountsLoading, personasLoading: false, @@ -57,7 +61,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { }) } - setConnectionLoading(true) + if (!silent) setConnectionLoading(true) let nextConnectionReady = false try { const connection = await api.get( @@ -69,9 +73,10 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { } catch { setConnectionReady(false) } finally { - setConnectionLoading(false) + if (!silent) setConnectionLoading(false) } + hasLoadedRef.current = true return buildOnboardingSnapshot({ accountsLoading: false, personasLoading: false, @@ -84,7 +89,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { useEffect(() => { refresh().catch(() => undefined) - }, [accounts, activeAccountId, pathname, refresh]) + }, [activeAccountId, pathname, accounts.length, refresh]) const snapshot = useMemo( () => diff --git a/web/src/pages/BrandDetailPage.tsx b/frontend/src/pages/BrandDetailPage.tsx similarity index 100% rename from web/src/pages/BrandDetailPage.tsx rename to frontend/src/pages/BrandDetailPage.tsx diff --git a/web/src/pages/BrandProductEditPage.tsx b/frontend/src/pages/BrandProductEditPage.tsx similarity index 100% rename from web/src/pages/BrandProductEditPage.tsx rename to frontend/src/pages/BrandProductEditPage.tsx diff --git a/web/src/pages/BrandsPage.tsx b/frontend/src/pages/BrandsPage.tsx similarity index 100% rename from web/src/pages/BrandsPage.tsx rename to frontend/src/pages/BrandsPage.tsx diff --git a/web/src/pages/CopyMatrixPage.tsx b/frontend/src/pages/CopyMatrixPage.tsx similarity index 99% rename from web/src/pages/CopyMatrixPage.tsx rename to frontend/src/pages/CopyMatrixPage.tsx index a868244..5dc842a 100644 --- a/web/src/pages/CopyMatrixPage.tsx +++ b/frontend/src/pages/CopyMatrixPage.tsx @@ -11,6 +11,7 @@ import { } from '../lib/copyFlow' import type { ListPersonasData, PersonaData } from '../types/api' import type { CopyMissionData } from '../types/copyMission' +import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh' import { hasPersona8D } from '../lib/styleProfile' import { copyMissionStatusLabel, copyMissionStatusTone, hasMissionResearchMap } from '../types/copyMission' @@ -171,6 +172,7 @@ export function CopyMatrixPage() { {}, { auth: true }, ) + requestJobMonitorRefresh() setLabel('') setSeedQuery('') setBrief('') diff --git a/web/src/pages/CopyMissionDetailPage.tsx b/frontend/src/pages/CopyMissionDetailPage.tsx similarity index 99% rename from web/src/pages/CopyMissionDetailPage.tsx rename to frontend/src/pages/CopyMissionDetailPage.tsx index 7fd6e4a..625be84 100644 --- a/web/src/pages/CopyMissionDetailPage.tsx +++ b/frontend/src/pages/CopyMissionDetailPage.tsx @@ -22,6 +22,7 @@ import { threadsAuthorPostUrl, threadsProfileUrl } from '../lib/threadsLinks' import { hasPersona8D } from '../lib/styleProfile' import { bindCopyIslanderActions, ensureCopyActionHandlers, unbindCopyIslanderActions } from '../lib/islander/copyActions' import { useIslanderPage } from '../lib/islander' +import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh' import { formatActiveJobConflictError, isActiveJobStatus } from '../lib/jobStatus' import type { CopyDraftData, JobData, PersonaData, ViralScanPostData } from '../types/api' import { @@ -382,6 +383,7 @@ export function CopyMissionDetailPage() { if (started.job_id) { rememberCopyMissionJobIds(missionId, { analyzeJobId: started.job_id }) setAnalyzeJobId(started.job_id) + requestJobMonitorRefresh() await refreshJobById(started.job_id) } setMessage(started.message || '研究地圖產生中…') @@ -452,6 +454,7 @@ export function CopyMissionDetailPage() { if (started.job_id) { rememberCopyMissionJobIds(missionId, { scanJobId: started.job_id }) setScanJobId(started.job_id) + requestJobMonitorRefresh() await refreshJobById(started.job_id) } setPosts([]) @@ -492,6 +495,7 @@ export function CopyMissionDetailPage() { if (started.job_id) { rememberCopyMissionJobIds(missionId, { matrixJobId: started.job_id }) setMatrixJobId(started.job_id) + requestJobMonitorRefresh() await refreshJobById(started.job_id) } setMessage(started.message || '內容矩陣產出中…') @@ -615,6 +619,7 @@ export function CopyMissionDetailPage() { ) if (started.job_id) { rememberCopyMissionReplicateJobId(missionId, scanPostId, started.job_id) + requestJobMonitorRefresh() await refreshJobById(started.job_id) } setMessage(started.message || '深仿寫產出中…') diff --git a/web/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx similarity index 100% rename from web/src/pages/DashboardPage.tsx rename to frontend/src/pages/DashboardPage.tsx diff --git a/web/src/pages/EasterEggsPage.tsx b/frontend/src/pages/EasterEggsPage.tsx similarity index 100% rename from web/src/pages/EasterEggsPage.tsx rename to frontend/src/pages/EasterEggsPage.tsx diff --git a/web/src/pages/JobDetailPage.tsx b/frontend/src/pages/JobDetailPage.tsx similarity index 100% rename from web/src/pages/JobDetailPage.tsx rename to frontend/src/pages/JobDetailPage.tsx diff --git a/web/src/pages/JobSchedulesPage.tsx b/frontend/src/pages/JobSchedulesPage.tsx similarity index 100% rename from web/src/pages/JobSchedulesPage.tsx rename to frontend/src/pages/JobSchedulesPage.tsx diff --git a/web/src/pages/JobTemplatesPage.tsx b/frontend/src/pages/JobTemplatesPage.tsx similarity index 100% rename from web/src/pages/JobTemplatesPage.tsx rename to frontend/src/pages/JobTemplatesPage.tsx diff --git a/web/src/pages/JobsPage.tsx b/frontend/src/pages/JobsPage.tsx similarity index 100% rename from web/src/pages/JobsPage.tsx rename to frontend/src/pages/JobsPage.tsx diff --git a/web/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx similarity index 100% rename from web/src/pages/LoginPage.tsx rename to frontend/src/pages/LoginPage.tsx diff --git a/web/src/pages/PermissionsPage.tsx b/frontend/src/pages/PermissionsPage.tsx similarity index 100% rename from web/src/pages/PermissionsPage.tsx rename to frontend/src/pages/PermissionsPage.tsx diff --git a/web/src/pages/PersonaDetailPage.tsx b/frontend/src/pages/PersonaDetailPage.tsx similarity index 99% rename from web/src/pages/PersonaDetailPage.tsx rename to frontend/src/pages/PersonaDetailPage.tsx index 7aa065d..9ea7180 100644 --- a/web/src/pages/PersonaDetailPage.tsx +++ b/frontend/src/pages/PersonaDetailPage.tsx @@ -26,6 +26,7 @@ import { SuccessText, Textarea, } from '../components/ui' +import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh' import { isTerminalJobStatus } from '../lib/jobStatus' import { createEmptyStyle8DProfile, @@ -186,6 +187,7 @@ export function PersonaDetailPage() { ) setMessage(data.message ?? '8D 分析已在背景執行,可自由切換頁面') setStyleJobId(data.job_id) + requestJobMonitorRefresh() await refreshStyleJob(data.job_id) } catch (e) { setError(e instanceof ApiError ? e.message : '8D 分析失敗') diff --git a/web/src/pages/PersonaMatrixPage.tsx b/frontend/src/pages/PersonaMatrixPage.tsx similarity index 100% rename from web/src/pages/PersonaMatrixPage.tsx rename to frontend/src/pages/PersonaMatrixPage.tsx diff --git a/web/src/pages/PersonaOutreachPage.tsx b/frontend/src/pages/PersonaOutreachPage.tsx similarity index 100% rename from web/src/pages/PersonaOutreachPage.tsx rename to frontend/src/pages/PersonaOutreachPage.tsx diff --git a/web/src/pages/PersonaResearchPage.tsx b/frontend/src/pages/PersonaResearchPage.tsx similarity index 99% rename from web/src/pages/PersonaResearchPage.tsx rename to frontend/src/pages/PersonaResearchPage.tsx index e6647f0..716d8e7 100644 --- a/web/src/pages/PersonaResearchPage.tsx +++ b/frontend/src/pages/PersonaResearchPage.tsx @@ -20,6 +20,7 @@ import { Notice, SuccessText, } from '../components/ui' +import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh' import { isTerminalJobStatus } from '../lib/jobStatus' import { placementFlowPath } from '../lib/placementFlow' import { @@ -257,6 +258,7 @@ export function PersonaResearchPage() { ) setMessage(data.message ?? '圖譜擴展已在背景執行') setExpandJobId(data.job_id) + requestJobMonitorRefresh() await refreshExpandJob(data.job_id) } catch (e) { setError(e instanceof ApiError ? e.message : '觸發圖譜擴展失敗') @@ -298,6 +300,7 @@ export function PersonaResearchPage() { ) setMessage(data.message ?? '雙軌海巡已在背景執行') setScanJobId(data.job_id) + requestJobMonitorRefresh() await refreshScanJob(data.job_id) } catch (e) { setError(e instanceof ApiError ? e.message : '啟動海巡失敗') diff --git a/web/src/pages/PersonasPage.tsx b/frontend/src/pages/PersonasPage.tsx similarity index 100% rename from web/src/pages/PersonasPage.tsx rename to frontend/src/pages/PersonasPage.tsx diff --git a/web/src/pages/PlacementTopicResearchMapPage.tsx b/frontend/src/pages/PlacementTopicResearchMapPage.tsx similarity index 99% rename from web/src/pages/PlacementTopicResearchMapPage.tsx rename to frontend/src/pages/PlacementTopicResearchMapPage.tsx index 59935c7..33b0a2d 100644 --- a/web/src/pages/PlacementTopicResearchMapPage.tsx +++ b/frontend/src/pages/PlacementTopicResearchMapPage.tsx @@ -13,6 +13,7 @@ import { type ExpandKnowledgeGraphData, type KnowledgeGraphData, } from '../lib/knowledgeGraph' +import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh' import { placementFlowPath } from '../lib/placementFlow' import { activeExpandJobHint, @@ -260,6 +261,7 @@ export function PlacementTopicResearchMapPage() { ) setMessage(data.message || `已用 ${keywords.length} 組關鍵字啟動雙軌海巡`) setScanJobId(data.job_id) + requestJobMonitorRefresh() await refreshScanJob(data.job_id) } catch (e) { const msg = e instanceof ApiError ? e.message : '啟動海巡失敗' @@ -286,6 +288,7 @@ export function PlacementTopicResearchMapPage() { { auth: true }, ) setExpandJobId(job.job_id) + requestJobMonitorRefresh() await refreshExpandJob(job.job_id) setMessage(job.message || '研究地圖產生中…') } catch (e) { diff --git a/web/src/pages/PlacementTopicSettingsPage.tsx b/frontend/src/pages/PlacementTopicSettingsPage.tsx similarity index 98% rename from web/src/pages/PlacementTopicSettingsPage.tsx rename to frontend/src/pages/PlacementTopicSettingsPage.tsx index 8cf826c..78a0bd7 100644 --- a/web/src/pages/PlacementTopicSettingsPage.tsx +++ b/frontend/src/pages/PlacementTopicSettingsPage.tsx @@ -5,6 +5,7 @@ import { BrandProductPicker } from '../components/BrandProductPicker' import { rememberTopicId } from '../lib/brandContext' import { brandHasCatalogProducts } from '../lib/productCatalog' import type { ExpandKnowledgeGraphData } from '../lib/knowledgeGraph' +import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh' import { formatActiveJobConflictError } from '../lib/jobStatus' import { hasResearchMap, topicResearchMapPath, topicTitle } from '../lib/placementTopics' import type { BrandData, ListBrandsData } from '../types/brand' @@ -123,6 +124,7 @@ export function PlacementTopicSettingsPage() { { seed_query: seedQuery.trim(), regenerate_map: true }, { auth: true }, ) + requestJobMonitorRefresh() navigate(topicResearchMapPath(id), { state: { expandJobId: job.job_id } }) } catch (e) { const raw = e instanceof ApiError ? e.message : '產生研究地圖失敗' diff --git a/web/src/pages/PlacementTopicsPage.tsx b/frontend/src/pages/PlacementTopicsPage.tsx similarity index 81% rename from web/src/pages/PlacementTopicsPage.tsx rename to frontend/src/pages/PlacementTopicsPage.tsx index 6842c0d..e18617a 100644 --- a/web/src/pages/PlacementTopicsPage.tsx +++ b/frontend/src/pages/PlacementTopicsPage.tsx @@ -35,43 +35,50 @@ export function PlacementTopicsPage() { suggestions: ['幫我新增一個主題', '哪個主題可以寫留言?'], }) - const load = useCallback(async () => { + const load = useCallback(async (opts?: { silent?: boolean }) => { setError('') - const [topicsRes, brandsRes] = await Promise.allSettled([ - api.get('/api/v1/placement/topics/', { auth: true }), - api.get('/api/v1/brands/', { auth: true }), - ]) - if (topicsRes.status === 'fulfilled') { - setTopics(topicsRes.value.list ?? []) - } else { - const reason = topicsRes.reason - setError(reason instanceof ApiError ? reason.message : '載入主題失敗') - setTopics([]) - } - if (brandsRes.status === 'fulfilled') { - setCatalogBrands(brandsRes.value.list ?? []) + if (!opts?.silent) setLoading(true) + try { + const [topicsRes, brandsRes] = await Promise.allSettled([ + api.get('/api/v1/placement/topics/', { auth: true }), + api.get('/api/v1/brands/', { auth: true }), + ]) + if (topicsRes.status === 'fulfilled') { + setTopics(topicsRes.value.list ?? []) + } else { + const reason = topicsRes.reason + setError(reason instanceof ApiError ? reason.message : '載入主題失敗') + setTopics([]) + } + if (brandsRes.status === 'fulfilled') { + setCatalogBrands(brandsRes.value.list ?? []) + } + } finally { + if (!opts?.silent) setLoading(false) } }, []) useEffect(() => { - setLoading(true) load() .catch((e) => setError(e instanceof ApiError ? e.message : '載入失敗')) - .finally(() => setLoading(false)) }, [load]) useEffect(() => { - const refresh = () => { + let debounceTimer: ReturnType | null = null + const scheduleRefresh = () => { if (document.visibilityState !== 'visible') return - load().catch(() => undefined) + if (createOpen) return + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + load({ silent: true }).catch(() => undefined) + }, 800) } - window.addEventListener('focus', refresh) - document.addEventListener('visibilitychange', refresh) + document.addEventListener('visibilitychange', scheduleRefresh) return () => { - window.removeEventListener('focus', refresh) - document.removeEventListener('visibilitychange', refresh) + if (debounceTimer) clearTimeout(debounceTimer) + document.removeEventListener('visibilitychange', scheduleRefresh) } - }, [load]) + }, [load, createOpen]) const filtered = useMemo(() => { const q = search.trim().toLowerCase() @@ -81,7 +88,7 @@ export function PlacementTopicsPage() { const onCreated = async (data: CreatePlacementTopicData) => { rememberTopicId(data.topic.id) - await load().catch(() => undefined) + await load({ silent: true }).catch(() => undefined) navigate(topicResearchMapPath(data.topic.id), { state: { expandJobId: data.job_id } }) } diff --git a/web/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx similarity index 100% rename from web/src/pages/ProfilePage.tsx rename to frontend/src/pages/ProfilePage.tsx diff --git a/web/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx similarity index 100% rename from web/src/pages/SettingsPage.tsx rename to frontend/src/pages/SettingsPage.tsx diff --git a/web/src/pages/ThreadsAccountConnectionsPage.tsx b/frontend/src/pages/ThreadsAccountConnectionsPage.tsx similarity index 100% rename from web/src/pages/ThreadsAccountConnectionsPage.tsx rename to frontend/src/pages/ThreadsAccountConnectionsPage.tsx diff --git a/web/src/pages/ThreadsAccountPublishPage.tsx b/frontend/src/pages/ThreadsAccountPublishPage.tsx similarity index 100% rename from web/src/pages/ThreadsAccountPublishPage.tsx rename to frontend/src/pages/ThreadsAccountPublishPage.tsx diff --git a/web/src/theme/ThemeContext.tsx b/frontend/src/theme/ThemeContext.tsx similarity index 100% rename from web/src/theme/ThemeContext.tsx rename to frontend/src/theme/ThemeContext.tsx diff --git a/web/src/threads/ThreadsAccountContext.tsx b/frontend/src/threads/ThreadsAccountContext.tsx similarity index 100% rename from web/src/threads/ThreadsAccountContext.tsx rename to frontend/src/threads/ThreadsAccountContext.tsx diff --git a/web/src/types/api.ts b/frontend/src/types/api.ts similarity index 92% rename from web/src/types/api.ts rename to frontend/src/types/api.ts index 11f2a10..8290840 100644 --- a/web/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -250,6 +250,32 @@ export interface ImportThreadsAccountSessionData { update_at: number } +export interface AIProviderOption { + id: string + label: string + streams: boolean +} + +export interface AIProvidersData { + providers: AIProviderOption[] +} + +export interface AIProviderModelsData { + id: string + label: string + models: string[] + streams: boolean + error?: string +} + +export interface ThreadsAccountAiProviderModelsData { + id: string + label: string + models: string[] + streams: boolean + error?: string +} + export interface ThreadsAccountAiSettingsData { account_id: string provider: string diff --git a/web/src/types/brand.ts b/frontend/src/types/brand.ts similarity index 100% rename from web/src/types/brand.ts rename to frontend/src/types/brand.ts diff --git a/web/src/types/copyMission.ts b/frontend/src/types/copyMission.ts similarity index 100% rename from web/src/types/copyMission.ts rename to frontend/src/types/copyMission.ts diff --git a/web/src/types/placementTopic.ts b/frontend/src/types/placementTopic.ts similarity index 100% rename from web/src/types/placementTopic.ts rename to frontend/src/types/placementTopic.ts diff --git a/web/src/vite-env.d.ts b/frontend/src/vite-env.d.ts similarity index 100% rename from web/src/vite-env.d.ts rename to frontend/src/vite-env.d.ts diff --git a/web/tsconfig.app.json b/frontend/tsconfig.app.json similarity index 100% rename from web/tsconfig.app.json rename to frontend/tsconfig.app.json diff --git a/web/tsconfig.json b/frontend/tsconfig.json similarity index 100% rename from web/tsconfig.json rename to frontend/tsconfig.json diff --git a/web/tsconfig.node.json b/frontend/tsconfig.node.json similarity index 100% rename from web/tsconfig.node.json rename to frontend/tsconfig.node.json diff --git a/web/vite.config.ts b/frontend/vite.config.ts similarity index 100% rename from web/vite.config.ts rename to frontend/vite.config.ts diff --git a/infra/.env.example b/infra/.env.example new file mode 100644 index 0000000..4780b12 --- /dev/null +++ b/infra/.env.example @@ -0,0 +1,20 @@ +# Infra docker compose 環境變數範本 +# 複製成 infra/.env 後填入實際值;.env 不要 commit。 + +# --- Mongo --- +MONGO_PORT=27017 +MONGO_ROOT_USER=haixun +MONGO_ROOT_PASSWORD=1qaz@WSX3edc$RFV +MONGO_DATABASE=haixun + +# --- Redis --- +REDIS_PORT=6379 +REDIS_PASSWORD=change-me-redis-pass + +INIT_ADMIN_EMAIL=admin@30cm.net +PASSWORD=Fafafafa54088 + +HAIXUN_JWT_ACCESS_SECRET=1qaz@WSX3edc$RFV +HAIXUN_JWT_REFRESH_SECRET=1qaz@WSX3edc$RFV +HAIXUN_SECRETS_KEY=1qaz@WSX3edc$RFV +HAIXUN_WORKER_SECRET=1qaz@WSX3edc$RFV diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..afb57fd --- /dev/null +++ b/infra/README.md @@ -0,0 +1,103 @@ +# 巡樓部署 (infra) + +部署拓樸: + +``` +瀏覽器 → nginx(systemd, :80/:443) + ├─ 靜態前端 /var/www/haixun (frontend/dist) + └─ /api 反向代理 → Go gateway (systemd, 127.0.0.1:8890) +Go gateway / Go worker (systemd) → Mongo / Redis (docker compose, 綁 127.0.0.1) +Node playwright worker (systemd) → 透過 HTTP 打 gateway +``` + +- 資料服務(Mongo/Redis)用 docker compose,只綁 `127.0.0.1`。 +- Go gateway / Go worker / Node worker 都是 systemd 原生服務。 +- secret 一律放 `/opt/haixun/etc/haixun.env`(不進 repo),yaml 用 `${VAR}` 讀取。 + +## 目錄 + +``` +infra/ + docker-compose.yml # mongo + redis + .env.example # compose 用環境變數 + etc/haixun.env.example # systemd EnvironmentFile 範本(secret) + nginx/haixun.conf # 靜態前端 + /api 反代 + SSE + systemd/ + haixun-gateway.service + haixun-worker.service + haixun-node-worker.service +``` + +## 1. 起資料服務 (docker) + +```bash +cd infra +cp .env.example .env # 填入 Mongo/Redis 密碼 +docker compose --env-file .env up -d +docker compose ps +``` + +## 2. 建置產物(本機或 CI) + +```bash +make build # 前端 dist + 兩個 linux Go binary(backend/bin/) +``` + +## 3. 安裝到目標主機 + +於目標主機(需 root): + +```bash +sudo make install +``` + +`make install` 會: + +1. 建立使用者 `haixun` 與目錄 `/opt/haixun/{bin,etc,node-worker}`、`/var/www/haixun`。 +2. 複製 `backend/bin/{gateway,worker}`、`backend/etc/gateway.prod.yaml`、`backend/etc/gateway.worker.prod.yaml`。 +3. 複製 `frontend/dist/*` → `/var/www/haixun`。 +4. 複製 `backend/worker/*`(Node worker)→ `/opt/haixun/node-worker`,並 `npm ci` + `npx playwright install`。 +5. 安裝 `infra/systemd/*.service` 與 `infra/nginx/haixun.conf`。 + +接著手動建立 secret 檔(**只做一次**): + +```bash +sudo cp infra/etc/haixun.env.example /opt/haixun/etc/haixun.env +sudo chmod 600 /opt/haixun/etc/haixun.env +sudoedit /opt/haixun/etc/haixun.env # 填入實際 secret +``` + +## 4. 初始化資料庫與 admin 帳號(只做一次) + +Mongo 起來、secret 填好後,建立索引 / 權限 catalog / role_permissions,並建立第一個 admin: + +```bash +# 可在 haixun.env 內設定 INIT_ADMIN_EMAIL / INIT_ADMIN_PASSWORD,或在這裡用環境變數覆寫 +sudo make prod-init +# 等同:source /opt/haixun/etc/haixun.env 後執行 /opt/haixun/bin/tool init -f /opt/haixun/etc/gateway.prod.yaml +``` + +之後一般使用者可走 `POST /api/v1/auth/register` 自助註冊(前端登入頁)。 + +## 5. 啟用服務 + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now haixun-gateway haixun-worker haixun-node-worker +sudo nginx -t && sudo systemctl reload nginx +``` + +## 6. 健康檢查 + +```bash +curl http://127.0.0.1:8890/api/v1/health +sudo systemctl status haixun-gateway haixun-worker haixun-node-worker +journalctl -u haixun-gateway -f +``` + +## 產生 secret + +```bash +openssl rand -base64 48 # JWT access / refresh / worker secret +openssl rand -base64 32 # HAIXUN_SECRETS_KEY(機敏資料落地加密) +``` diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..fec1b16 --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,42 @@ +# 巡樓資料服務:Mongo + Redis +# 只綁 127.0.0.1,給同主機上以 systemd 跑的 Go gateway / worker 連線。 +# 啟動:docker compose -f infra/docker-compose.yml --env-file infra/.env up -d +name: haixun-infra + +services: + mongo: + image: mongo:7 + restart: unless-stopped + ports: + - "127.0.0.1:${MONGO_PORT:-27017}:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-haixun} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD is required} + MONGO_INITDB_DATABASE: ${MONGO_DATABASE:-haixun} + volumes: + - mongo_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + + redis: + image: redis:7 + restart: unless-stopped + command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?REDIS_PASSWORD is required}", "--appendonly", "yes"] + ports: + - "127.0.0.1:${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + mongo_data: + redis_data: diff --git a/infra/etc/haixun.env.example b/infra/etc/haixun.env.example new file mode 100644 index 0000000..903366c --- /dev/null +++ b/infra/etc/haixun.env.example @@ -0,0 +1,23 @@ +# 部署到目標主機的 /opt/haixun/etc/haixun.env(chmod 600,不要 commit 實值) +# gateway.prod.yaml / gateway.worker.yaml 用 ${VAR} 讀取這些值(go-zero conf.UseEnv)。 + +# Mongo(含 docker compose 設定的帳密;authSource=admin) +HAIXUN_MONGO_URI=mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin +HAIXUN_MONGO_DB=haixun + +# Redis +HAIXUN_REDIS_ADDR=127.0.0.1:6379 +HAIXUN_REDIS_PASSWORD=change-me-redis-pass + +# JWT secret(請用 openssl rand -base64 48 產生,兩把不同) +HAIXUN_JWT_ACCESS_SECRET=replace-with-strong-random +HAIXUN_JWT_REFRESH_SECRET=replace-with-another-strong-random + +# 內部 worker secret(gateway 與 node worker 必須一致) +HAIXUN_WORKER_SECRET=replace-with-strong-random + +# 機敏資料落地加密金鑰(base64 編碼的 32 bytes;openssl rand -base64 32) +HAIXUN_SECRETS_KEY=replace-with-base64-32-bytes + +# Node worker 連線設定 +HAIXUN_BACKEND_URL=http://127.0.0.1:8890 diff --git a/infra/nginx/haixun.conf b/infra/nginx/haixun.conf new file mode 100644 index 0000000..69db9e0 --- /dev/null +++ b/infra/nginx/haixun.conf @@ -0,0 +1,56 @@ +# 巡樓 Console nginx 設定 +# 安裝:cp infra/nginx/haixun.conf /etc/nginx/conf.d/haixun.conf && nginx -t && systemctl reload nginx +# 前端靜態檔部署在 /var/www/haixun(make install 會放 frontend/dist 內容)。 +# /api 反向代理到本機 systemd 跑的 Go gateway (127.0.0.1:8890),含 SSE 串流設定。 + +upstream haixun_gateway { + server 127.0.0.1:8890; + keepalive 32; +} + +server { + listen 80; + listen [::]:80; + server_name _; + + root /var/www/haixun; + index index.html; + + # 安全標頭 + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-XSS-Protection "0" always; + + # 靜態資源快取(vite build 帶 hash 檔名) + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # API 反向代理(一般 JSON 與 SSE 共用) + location /api/ { + proxy_pass http://haixun_gateway; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Authorization(provider token)與 X-Member-Authorization(會員 JWT)由 nginx 預設轉發; + # 以下確保 SSE 串流不被緩衝、長連線不被提前中斷。 + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + chunked_transfer_encoding off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + # SPA:所有非檔案路徑都回 index.html,交給 react-router + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/infra/systemd/haixun-gateway.service b/infra/systemd/haixun-gateway.service new file mode 100644 index 0000000..9030f3f --- /dev/null +++ b/infra/systemd/haixun-gateway.service @@ -0,0 +1,25 @@ +[Unit] +Description=Haixun Gateway API (go-zero) +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=simple +User=haixun +Group=haixun +WorkingDirectory=/opt/haixun +# secrets(JWT / Mongo URI / Redis 密碼 / worker secret / 加密金鑰)放這裡,不進 repo +EnvironmentFile=/opt/haixun/etc/haixun.env +ExecStart=/opt/haixun/bin/gateway -f /opt/haixun/etc/gateway.prod.yaml +Restart=always +RestartSec=5 +LimitNOFILE=65535 + +# 加固 +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/infra/systemd/haixun-node-worker.service b/infra/systemd/haixun-node-worker.service new file mode 100644 index 0000000..7221767 --- /dev/null +++ b/infra/systemd/haixun-node-worker.service @@ -0,0 +1,23 @@ +[Unit] +Description=Haixun Node Playwright Worker (style-8d) +After=network-online.target haixun-gateway.service +Wants=network-online.target + +[Service] +Type=simple +User=haixun +Group=haixun +WorkingDirectory=/opt/haixun/node-worker +# 至少需要 HAIXUN_BACKEND_URL 與 HAIXUN_WORKER_SECRET(與 gateway 的 InternalWorker.Secret 一致) +EnvironmentFile=/opt/haixun/etc/haixun.env +ExecStart=/usr/bin/npx tsx style-8d-worker.ts +Restart=always +RestartSec=10 +LimitNOFILE=65535 + +NoNewPrivileges=true +ProtectSystem=full +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/infra/systemd/haixun-worker.service b/infra/systemd/haixun-worker.service new file mode 100644 index 0000000..df7a3e6 --- /dev/null +++ b/infra/systemd/haixun-worker.service @@ -0,0 +1,23 @@ +[Unit] +Description=Haixun Go Job Worker +After=network-online.target docker.service haixun-gateway.service +Wants=network-online.target + +[Service] +Type=simple +User=haixun +Group=haixun +WorkingDirectory=/opt/haixun +EnvironmentFile=/opt/haixun/etc/haixun.env +ExecStart=/opt/haixun/bin/worker -f /opt/haixun/etc/gateway.worker.prod.yaml +Restart=always +RestartSec=5 +LimitNOFILE=65535 + +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/internal/middleware/auth_middleware.go b/internal/middleware/auth_middleware.go deleted file mode 100644 index 1ac202c..0000000 --- a/internal/middleware/auth_middleware.go +++ /dev/null @@ -1,22 +0,0 @@ -// Code scaffolded by goctl. Safe to edit. -// goctl 1.10.1 - -package middleware - -import "net/http" - -type AuthMiddleware struct { -} - -func NewAuthMiddleware() *AuthMiddleware { - return &AuthMiddleware{} -} - -func (m *AuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - // TODO generate middleware implement function, delete after code implementation - - // Passthrough to next handler if need - next(w, r) - } -} diff --git a/internal/model/scan_post/domain/entity/post.go b/internal/model/scan_post/domain/entity/post.go deleted file mode 100644 index dbbd81f..0000000 --- a/internal/model/scan_post/domain/entity/post.go +++ /dev/null @@ -1,60 +0,0 @@ -package entity - -const CollectionName = "scan_posts" - -const FlowViral = "viral" -const FlowPlacement = "placement" - -type ScanReply struct { - ExternalID string `bson:"external_id,omitempty"` - Author string `bson:"author,omitempty"` - Text string `bson:"text"` - Permalink string `bson:"permalink,omitempty"` - LikeCount int `bson:"like_count,omitempty"` - PostedAt string `bson:"posted_at,omitempty"` -} - -type ScanPost struct { - ID string `bson:"_id"` - TenantID string `bson:"tenant_id"` - OwnerUID string `bson:"owner_uid"` - BrandID string `bson:"brand_id"` - TopicID string `bson:"topic_id,omitempty"` - LegacyPersonaID string `bson:"persona_id,omitempty"` - CopyMissionID string `bson:"copy_mission_id,omitempty"` - Flow string `bson:"flow,omitempty"` - GraphID string `bson:"graph_id"` - ScanJobID string `bson:"scan_job_id"` - GraphNodeID string `bson:"graph_node_id"` - SearchTag string `bson:"search_tag"` - QueryDimension string `bson:"query_dimension"` - ExternalID string `bson:"external_id"` - Permalink string `bson:"permalink"` - Author string `bson:"author"` - AuthorVerified bool `bson:"author_verified,omitempty"` - FollowerCount int `bson:"follower_count,omitempty"` - Text string `bson:"text"` - Priority string `bson:"priority"` - LikeCount int `bson:"like_count,omitempty"` - ReplyCount int `bson:"reply_count,omitempty"` - EngagementScore int `bson:"engagement_score,omitempty"` - PlacementScore int `bson:"placement_score"` - ProductFitScore int `bson:"product_fit_score"` - SolvedByProduct bool `bson:"solved_by_product"` - Source string `bson:"source"` - OutreachStatus string `bson:"outreach_status,omitempty"` - PublishedReplyID string `bson:"published_reply_id,omitempty"` - PublishedPermalink string `bson:"published_permalink,omitempty"` - OutreachUpdateAt int64 `bson:"outreach_update_at,omitempty"` - PostedAt string `bson:"posted_at,omitempty"` - Replies []ScanReply `bson:"replies,omitempty"` - CreateAt int64 `bson:"create_at"` -} - -const ( - OutreachStatusPending = "pending" - OutreachStatusDrafted = "drafted" - OutreachStatusPublished = "published" - OutreachStatusHandled = "handled" - OutreachStatusSkipped = "skipped" -) diff --git a/scripts/debug-opencode-raw.sh b/scripts/debug-opencode-raw.sh deleted file mode 100755 index b92e53c..0000000 --- a/scripts/debug-opencode-raw.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -# 直接對比 OpenCode upstream 與本機 gateway 的原始 JSON/SSE,方便排查空回應 -# -# 用法: -# OPENCODE_TOKEN=sk-xxx ./scripts/debug-opencode-raw.sh - -set -u - -BASE_URL="${BASE_URL:-http://127.0.0.1:8890}" -OPENCODE_TOKEN="${OPENCODE_TOKEN:-}" -MODEL="${MODEL:-deepseek-v4-flash}" -MESSAGE="${MESSAGE:-Introduce yourself in one sentence.}" - -if [[ -z "$OPENCODE_TOKEN" ]]; then - echo "OPENCODE_TOKEN is required" - exit 1 -fi - -require_cmd() { - command -v "$1" >/dev/null 2>&1 || { echo "missing command: $1"; exit 1; } -} - -require_cmd curl -require_cmd jq - -payload="$(jq -n \ - --arg model "$MODEL" \ - --arg message "$MESSAGE" \ - '{ - model: $model, - messages: [{role: "user", content: $message}], - max_tokens: 2048, - thinking: {type: "disabled"} - }')" - -echo "=== OpenCode upstream (non-stream) ===" -curl -sS -m 120 -X POST "https://opencode.ai/zen/go/v1/chat/completions" \ - -H "Authorization: Bearer ${OPENCODE_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "$payload" | jq . - -echo "" -echo "=== Local gateway (non-stream) ===" -curl -sS -m 120 -X POST "${BASE_URL}/api/v1/ai/chat" \ - -H "Authorization: Bearer ${OPENCODE_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "$(jq -n \ - --arg provider "opencode-go" \ - --arg model "$MODEL" \ - --arg message "$MESSAGE" \ - '{provider:$provider,model:$model,messages:[{role:"user",content:$message}],max_tokens:2048}')" | jq . - -echo "" -echo "=== OpenCode upstream (stream, first 20 lines) ===" -curl -sS -N -m 120 -X POST "https://opencode.ai/zen/go/v1/chat/completions" \ - -H "Authorization: Bearer ${OPENCODE_TOKEN}" \ - -H "Content-Type: application/json" \ - -H "Accept: text/event-stream" \ - -d "$(echo "$payload" | jq '.stream=true')" | head -n 20 - -echo "" -echo "=== Local gateway (stream, first 20 lines) ===" -curl -sS -N -m 120 -X POST "${BASE_URL}/api/v1/ai/chat/stream" \ - -H "Authorization: Bearer ${OPENCODE_TOKEN}" \ - -H "Content-Type: application/json" \ - -H "Accept: text/event-stream" \ - -d "$(jq -n \ - --arg provider "opencode-go" \ - --arg model "$MODEL" \ - --arg message "$MESSAGE" \ - '{provider:$provider,model:$model,messages:[{role:"user",content:$message}],max_tokens:2048}')" | head -n 20 \ No newline at end of file diff --git a/scripts/dev-with-style-8d.sh b/scripts/dev-with-style-8d.sh deleted file mode 100644 index 94997b6..0000000 --- a/scripts/dev-with-style-8d.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -BACKEND_DIR="$ROOT_DIR/haixun-backend" -CONFIG_FILE="${HAIXUN_BACKEND_CONFIG:-etc/gateway.yaml}" -BACKEND_URL="${HAIXUN_BACKEND_URL:-http://127.0.0.1:8890}" - -pids=() -owned_pids=() - -cleanup() { - local code=$? - if ((${#owned_pids[@]} > 0)); then - echo "" - echo "[dev-8d] stopping backend and worker..." - kill "${owned_pids[@]}" 2>/dev/null || true - wait "${owned_pids[@]}" 2>/dev/null || true - fi - exit "$code" -} - -trap cleanup EXIT INT TERM - -if curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then - echo "[dev-8d] backend already running: $BACKEND_URL" -else - echo "[dev-8d] starting Go backend: $CONFIG_FILE" - ( - cd "$BACKEND_DIR" - go run ./gateway.go -f "$CONFIG_FILE" - ) & - pids+=("$!") - owned_pids+=("$!") - - echo "[dev-8d] waiting for backend health..." - for _ in $(seq 1 30); do - if curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then - break - fi - if ! kill -0 "${pids[0]}" 2>/dev/null; then - wait "${pids[0]}" - fi - sleep 1 - done - - if ! curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then - echo "[dev-8d] backend health check timed out: $BACKEND_URL" >&2 - exit 1 - fi -fi - -echo "[dev-8d] starting Node 8D worker" -( - cd "$ROOT_DIR" - npm run worker:style-8d -) & -pids+=("$!") -owned_pids+=("$!") - -echo "[dev-8d] running pids=${pids[*]}" -echo "[dev-8d] press Ctrl+C to stop both" - -while true; do - for pid in "${owned_pids[@]}"; do - if ! kill -0 "$pid" 2>/dev/null; then - wait "$pid" - exit $? - fi - done - sleep 1 -done diff --git a/scripts/package-extension.sh b/scripts/package-extension.sh deleted file mode 100755 index 47f378c..0000000 --- a/scripts/package-extension.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -EXT_DIR="$ROOT_DIR/extension/haixun-threads-sync" -OUT_DIR="$ROOT_DIR/haixun-backend/web/public/downloads" -OUT_FILE="$OUT_DIR/haixun-threads-sync.zip" - -if [[ ! -f "$EXT_DIR/manifest.json" ]]; then - echo "extension not found: $EXT_DIR" >&2 - exit 1 -fi - -mkdir -p "$OUT_DIR" -rm -f "$OUT_FILE" - -( - cd "$(dirname "$EXT_DIR")" - zip -qr "$OUT_FILE" "$(basename "$EXT_DIR")" \ - -x "*.DS_Store" -x "*__MACOSX*" -) - -VERSION="$(python3 -c "import json; print(json.load(open('$EXT_DIR/manifest.json'))['version'])")" -echo "packed haixun-threads-sync v$VERSION -> $OUT_FILE" \ No newline at end of file diff --git a/scripts/prod-common.sh b/scripts/prod-common.sh deleted file mode 100755 index 51e9577..0000000 --- a/scripts/prod-common.sh +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env bash -# shellcheck disable=SC2034 -# Shared helpers for production Docker scripts. - -_PROD_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -BACKEND_DIR="$(cd "$_PROD_SCRIPT_DIR/.." && pwd)" -DEPLOY_DIR="$BACKEND_DIR/deploy" -COMPOSE_FILE="$DEPLOY_DIR/docker-compose.prod.yml" -ENV_FILE="$DEPLOY_DIR/.env" -ENV_EXAMPLE="$DEPLOY_DIR/.env.example" - -prod_common_init() { - : -} - -prod_load_env() { - prod_common_init - - if [[ ! -f "$ENV_FILE" ]]; then - if [[ -f "$ENV_EXAMPLE" ]]; then - cp "$ENV_EXAMPLE" "$ENV_FILE" - echo "[prod] created $ENV_FILE from .env.example — 請先修改密鑰與管理員密碼" - else - echo "[prod] missing $ENV_FILE" >&2 - exit 1 - fi - fi - - set -a - # shellcheck disable=SC1090 - source "$ENV_FILE" - set +a - - GO_REPLICAS="${GO_WORKER_REPLICAS:-1}" - NODE_REPLICAS="${NODE_STYLE8D_WORKER_REPLICAS:-1}" - WEB_PORT="${HAIXUN_WEB_PORT:-8080}" - MONGO_DB="${HAIXUN_MONGO_DATABASE:-haixun}" -} - -prod_require_docker() { - if ! command -v docker >/dev/null 2>&1; then - echo "[prod] docker is required" >&2 - exit 1 - fi -} - -prod_compose() { - prod_common_init - docker compose -f "$COMPOSE_FILE" "$@" -} - -prod_service_health() { - local service="$1" - prod_compose ps --format json "$service" 2>/dev/null \ - | grep -o '"Health":"[^"]*"' \ - | head -1 \ - | cut -d'"' -f4 \ - || true -} - -prod_named_container_health() { - local name="$1" - docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{if .State.Running}}running{{else}}stopped{{end}}{{end}}' \ - "$name" 2>/dev/null || true -} - -prod_named_container_running() { - local name="$1" - [[ "$(docker inspect --format '{{.State.Running}}' "$name" 2>/dev/null || echo false)" == "true" ]] -} - -prod_deps_healthy() { - local mongo_ok redis_ok - mongo_ok="$(prod_service_health mongo)" - redis_ok="$(prod_service_health redis)" - if [[ "$mongo_ok" == "healthy" && "$redis_ok" == "healthy" ]]; then - return 0 - fi - - # compose ps 偶發失敗時,改查實際 container(避免重複 create 撞名) - mongo_ok="$(prod_named_container_health haixun-prod-mongo-1)" - redis_ok="$(prod_named_container_health haixun-prod-redis-1)" - [[ "$mongo_ok" == "healthy" && "$redis_ok" == "healthy" ]] -} - -prod_wait_deps_healthy() { - echo "[prod] waiting for mongo/redis..." - for _ in $(seq 1 90); do - if prod_deps_healthy; then - return 0 - fi - sleep 1 - done - echo "[prod] mongo/redis did not become healthy in time" >&2 - exit 1 -} - -prod_ensure_deps() { - if prod_deps_healthy; then - echo "[prod] mongo + redis already healthy — 略過重啟(資料在 named volume)" - return 0 - fi - - echo "[prod] starting mongo + redis..." - if prod_named_container_running haixun-prod-mongo-1 && prod_named_container_running haixun-prod-redis-1; then - prod_compose start mongo redis 2>/dev/null \ - || prod_compose up -d --no-recreate mongo redis - else - prod_compose up -d mongo redis - fi - prod_wait_deps_healthy -} - -prod_mongo_has_members() { - prod_compose exec -T mongo mongosh --quiet "$MONGO_DB" --eval \ - 'db.members.countDocuments({})' 2>/dev/null \ - | tr -d '\r' \ - | grep -Eq '^[1-9][0-9]*$' -} - -prod_should_skip_init() { - if [[ "${HAIXUN_SKIP_INIT:-0}" == "1" ]]; then - return 0 - fi - if [[ "${PROD_FORCE_INIT:-0}" == "1" ]]; then - return 1 - fi - if prod_mongo_has_members; then - return 0 - fi - return 1 -} - -prod_run_init_if_needed() { - if prod_should_skip_init; then - if [[ "${HAIXUN_SKIP_INIT:-0}" == "1" ]]; then - echo "[prod] skip init (HAIXUN_SKIP_INIT=1)" - else - echo "[prod] skip init (Mongo 已有資料;若要強制重跑請設 PROD_FORCE_INIT=1)" - fi - return 0 - fi - - echo "[prod] running bootstrap init..." - prod_compose --profile init run --rm init -} - -prod_build_web_if_static() { - prod_common_init - if [[ "${HAIXUN_WEB_BUILD_MODE:-static}" == "static" ]]; then - echo "[prod] building frontend static files (vite → web/dist)..." - (cd "$BACKEND_DIR" && make web-build) - else - echo "[prod] HAIXUN_WEB_BUILD_MODE=docker — web image will compile inside Docker" - fi -} - -prod_start_app_services() { - local build_flag=() - if [[ "${PROD_SKIP_BUILD:-0}" != "1" ]]; then - build_flag=(--build) - fi - - echo "[prod] starting api, web, workers (go=${GO_REPLICAS}, node-style-8d=${NODE_REPLICAS})..." - prod_compose up -d "${build_flag[@]}" \ - --no-deps \ - --scale "go-worker=${GO_REPLICAS}" \ - --scale "node-worker-style-8d=${NODE_REPLICAS}" \ - api web go-worker node-worker-style-8d -} - -prod_wait_api_health() { - echo "[prod] waiting for API health..." - for _ in $(seq 1 60); do - if curl -fsS "http://127.0.0.1:${WEB_PORT}/api/v1/health" >/dev/null 2>&1; then - return 0 - fi - sleep 1 - done - echo "[prod] API health check timed out" >&2 - exit 1 -} - -prod_print_volume_hint() { - echo " Data: Mongo/Redis 使用 named volume(重啟 container 不會清資料)" - echo " Update app: make -C haixun-backend prod-update" - echo " Wipe data: make -C haixun-backend prod-wipe-data # 會刪除 volume" -} - -prod_print_stack_summary() { - echo "" - echo "[prod] stack is up" - echo " Web: http://127.0.0.1:${WEB_PORT}" - echo " API: http://127.0.0.1:${WEB_PORT}/api/v1/health (via nginx)" - echo " Go worker: ${GO_REPLICAS} replica(s)" - echo " Node 8D: ${NODE_REPLICAS} replica(s)" - echo " Env: ${ENV_FILE}" - echo " Stop: make -C haixun-backend prod-down" - echo " Logs: make -C haixun-backend prod-logs" - prod_print_volume_hint -} \ No newline at end of file diff --git a/scripts/prod-deps.sh b/scripts/prod-deps.sh deleted file mode 100755 index 3ac8b63..0000000 --- a/scripts/prod-deps.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# shellcheck source=scripts/prod-common.sh -source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/prod-common.sh" - -prod_load_env -prod_require_docker - -cd "$DEPLOY_DIR" - -prod_ensure_deps - -echo "" -echo "[prod] mongo + redis ready" -prod_print_volume_hint \ No newline at end of file diff --git a/scripts/prod-down.sh b/scripts/prod-down.sh deleted file mode 100755 index 94e9cf1..0000000 --- a/scripts/prod-down.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# shellcheck source=scripts/prod-common.sh -source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/prod-common.sh" - -prod_common_init -cd "$DEPLOY_DIR" - -prod_compose down --remove-orphans -echo "[prod] stopped(Mongo/Redis 資料仍在 named volume,下次 prod / prod-deps 會沿用)" \ No newline at end of file diff --git a/scripts/prod-logs.sh b/scripts/prod-logs.sh deleted file mode 100755 index 43fa9e5..0000000 --- a/scripts/prod-logs.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -BACKEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.prod.yml" - -cd "$BACKEND_DIR/deploy" -docker compose -f "$COMPOSE_FILE" logs -f --tail=200 "${@:-}" \ No newline at end of file diff --git a/scripts/prod-up.sh b/scripts/prod-up.sh deleted file mode 100755 index e3dce92..0000000 --- a/scripts/prod-up.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# shellcheck source=scripts/prod-common.sh -source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/prod-common.sh" - -prod_load_env -prod_require_docker - -cd "$BACKEND_DIR" -prod_build_web_if_static - -cd "$DEPLOY_DIR" - -echo "[prod] building images..." -prod_compose build - -prod_ensure_deps -prod_run_init_if_needed - -prod_start_app_services -prod_wait_api_health -prod_print_stack_summary \ No newline at end of file diff --git a/scripts/prod-update.sh b/scripts/prod-update.sh deleted file mode 100755 index 5398784..0000000 --- a/scripts/prod-update.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# 只重建/重啟 API、Web、Workers;不碰 mongo/redis(資料留在 volume)。 - -# shellcheck source=scripts/prod-common.sh -source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/prod-common.sh" - -prod_load_env -prod_require_docker - -cd "$BACKEND_DIR" -prod_build_web_if_static - -cd "$DEPLOY_DIR" - -if ! prod_deps_healthy; then - echo "[prod] mongo/redis 未在運行,先啟動依賴(不會清 volume)..." - prod_ensure_deps -else - echo "[prod] mongo + redis 維持運行 — 只更新應用層" -fi - -echo "[prod] building app images (api, web, workers)..." -prod_compose build api web go-worker node-worker-style-8d - -prod_start_app_services -prod_wait_api_health -prod_print_stack_summary \ No newline at end of file diff --git a/scripts/prod-wipe-data.sh b/scripts/prod-wipe-data.sh deleted file mode 100755 index 1bbb805..0000000 --- a/scripts/prod-wipe-data.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# 危險:停止 stack 並刪除 Mongo/Redis named volume。 - -# shellcheck source=scripts/prod-common.sh -source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/prod-common.sh" - -prod_load_env -prod_require_docker - -cd "$DEPLOY_DIR" - -echo "[prod] 這會刪除 haixun-prod_mongo_data 與 haixun-prod_redis_data 內所有資料。" -read -r -p "輸入 yes 才會繼續: " confirm -if [[ "$confirm" != "yes" ]]; then - echo "[prod] cancelled" - exit 1 -fi - -prod_compose down -v --remove-orphans -echo "[prod] volumes removed — 下次 make prod 會是全新資料庫" \ No newline at end of file diff --git a/scripts/restart-all.sh b/scripts/restart-all.sh deleted file mode 100755 index f8c1d04..0000000 --- a/scripts/restart-all.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -BACKEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -bash "$BACKEND_DIR/scripts/stop-all.sh" -bash "$BACKEND_DIR/scripts/start-all.sh" \ No newline at end of file diff --git a/scripts/start-all.sh b/scripts/start-all.sh deleted file mode 100755 index 98b9dba..0000000 --- a/scripts/start-all.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -BACKEND_DIR="$ROOT_DIR/haixun-backend" -RUN_DIR="$BACKEND_DIR/.run" -LOG_DIR="$RUN_DIR/logs" -COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.yml" -CONFIG_FILE="${HAIXUN_BACKEND_CONFIG:-etc/gateway.yaml}" -BACKEND_URL="${HAIXUN_BACKEND_URL:-http://127.0.0.1:8890}" -WEB_URL="${HAIXUN_WEB_URL:-http://127.0.0.1:5173}" - -mkdir -p "$RUN_DIR" "$LOG_DIR" - -bash "$BACKEND_DIR/scripts/stop-all.sh" - -if ! command -v docker >/dev/null 2>&1; then - echo "[start-all] docker not found; skip mongo/redis" >&2 -else - echo "[start-all] starting mongo + redis..." - docker compose -f "$COMPOSE_FILE" up -d -fi - -echo "[start-all] starting Go API ($CONFIG_FILE)..." -( - cd "$BACKEND_DIR" - go run ./gateway.go -f "$CONFIG_FILE" -) >"$LOG_DIR/api.log" 2>&1 & -echo $! >"$RUN_DIR/api.pid" - -echo "[start-all] waiting for API health ($BACKEND_URL)..." -for _ in $(seq 1 40); do - if curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then - break - fi - if ! kill -0 "$(cat "$RUN_DIR/api.pid")" 2>/dev/null; then - echo "[start-all] API exited early; see $LOG_DIR/api.log" >&2 - tail -n 20 "$LOG_DIR/api.log" >&2 || true - exit 1 - fi - sleep 1 -done -if ! curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then - echo "[start-all] API health check timed out; see $LOG_DIR/api.log" >&2 - exit 1 -fi - -if [[ ! -d "$BACKEND_DIR/web/node_modules" ]]; then - echo "[start-all] installing web dependencies..." - (cd "$BACKEND_DIR/web" && npm install) -fi - -echo "[start-all] starting web dev server..." -( - cd "$BACKEND_DIR/web" - npm run dev -) >"$LOG_DIR/web.log" 2>&1 & -echo $! >"$RUN_DIR/web.pid" - -echo "[start-all] starting Node 8D worker..." -( - cd "$ROOT_DIR" - npm run worker:style-8d -) >"$LOG_DIR/worker.log" 2>&1 & -echo $! >"$RUN_DIR/worker.pid" - -sleep 2 - -echo "" -echo "[start-all] all services started" -echo " API: $BACKEND_URL" -echo " Web: $WEB_URL" -echo " Logs: $LOG_DIR/{api,web,worker}.log" -echo " Stop: make -C haixun-backend stop-all" -echo " Status: make -C haixun-backend status-all" \ No newline at end of file diff --git a/scripts/status-all.sh b/scripts/status-all.sh deleted file mode 100755 index 7d740be..0000000 --- a/scripts/status-all.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -BACKEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -RUN_DIR="$BACKEND_DIR/.run" -COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.yml" -BACKEND_URL="${HAIXUN_BACKEND_URL:-http://127.0.0.1:8890}" -WEB_URL="${HAIXUN_WEB_URL:-http://127.0.0.1:5173}" - -check_pid() { - local name="$1" - local file="$RUN_DIR/${name}.pid" - if [[ -f "$file" ]]; then - local pid - pid="$(cat "$file" 2>/dev/null || true)" - if [[ -n "${pid:-}" ]] && kill -0 "$pid" 2>/dev/null; then - echo " $name: running (pid=$pid)" - return 0 - fi - fi - echo " $name: stopped" - return 1 -} - -echo "Haixun dev services" -echo "" - -if command -v docker >/dev/null 2>&1 && [[ -f "$COMPOSE_FILE" ]]; then - echo "Docker:" - docker compose -f "$COMPOSE_FILE" ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || echo " (docker compose not running)" - echo "" -fi - -echo "Processes:" -check_pid api || true -check_pid web || true -check_pid worker || true -echo "" - -echo "Health:" -if curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then - echo " API health: OK ($BACKEND_URL)" -else - echo " API health: down ($BACKEND_URL)" -fi -if curl -fsS "$WEB_URL" >/dev/null 2>&1; then - echo " Web: OK ($WEB_URL)" -else - echo " Web: down ($WEB_URL)" -fi \ No newline at end of file diff --git a/scripts/stop-all.sh b/scripts/stop-all.sh deleted file mode 100755 index e336405..0000000 --- a/scripts/stop-all.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -BACKEND_DIR="$ROOT_DIR/haixun-backend" -RUN_DIR="$BACKEND_DIR/.run" -COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.yml" - -stop_pid_file() { - local name="$1" - local file="$RUN_DIR/${name}.pid" - if [[ ! -f "$file" ]]; then - return 0 - fi - local pid - pid="$(cat "$file" 2>/dev/null || true)" - if [[ -n "${pid:-}" ]] && kill -0 "$pid" 2>/dev/null; then - echo "[stop-all] stopping $name (pid=$pid)" - kill "$pid" 2>/dev/null || true - for _ in $(seq 1 10); do - kill -0 "$pid" 2>/dev/null || break - sleep 0.2 - done - kill -9 "$pid" 2>/dev/null || true - fi - rm -f "$file" -} - -echo "[stop-all] stopping tracked processes..." -for name in worker web api; do - stop_pid_file "$name" -done - -echo "[stop-all] stopping stray processes..." -pkill -f "haixun-backend/worker/style-8d-worker" 2>/dev/null || true -pkill -f "worker:style-8d" 2>/dev/null || true -pkill -f "haixun-backend/web/node_modules/.bin/vite" 2>/dev/null || true -pkill -f "go run ./gateway.go -f etc/gateway.yaml" 2>/dev/null || true -# `go run` spawns a compiled binary child under the go-build cache (e.g. -# ~/Library/Caches/go-build/.../gateway) that is NOT killed when the parent -# wrapper dies; kill the orphan too so it stops serving stale routes on the API -# port and frees the port for the freshly built binary. -pkill -f "/gateway -f etc/gateway.yaml" 2>/dev/null || true -pkill -f "dev-with-style-8d.sh" 2>/dev/null || true - -if command -v docker >/dev/null 2>&1 && [[ -f "$COMPOSE_FILE" ]]; then - echo "[stop-all] stopping docker compose (mongo + redis)..." - docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true -fi - -echo "[stop-all] done" \ No newline at end of file diff --git a/scripts/test-job-cancel.sh b/scripts/test-job-cancel.sh deleted file mode 100755 index abdad27..0000000 --- a/scripts/test-job-cancel.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env bash -# 示範 job 建立與取消:running 時會透過 Redis jobs:cancel: 戳 worker -# -# 用法: -# ./scripts/test-job-cancel.sh - -set -u - -BASE_URL="${BASE_URL:-http://127.0.0.1:8890}" -SCOPE="${SCOPE:-user}" -SCOPE_ID="${SCOPE_ID:-demo_user_1}" - -require_cmd() { - command -v "$1" >/dev/null 2>&1 || { echo "missing command: $1"; exit 1; } -} - -require_cmd curl -require_cmd jq - -echo "== create demo_long_task ==" -CREATE_BODY="$(curl -sS -X POST "${BASE_URL}/api/v1/jobs" \ - -H "Content-Type: application/json" \ - -d "$(jq -n --arg scope "$SCOPE" --arg scope_id "$SCOPE_ID" '{ - template_type: "demo_long_task", - scope: $scope, - scope_id: $scope_id, - payload: {target: "demo"} - }')")" - -echo "$CREATE_BODY" | jq . -JOB_ID="$(echo "$CREATE_BODY" | jq -r '.data.id // empty')" -if [[ -z "$JOB_ID" ]]; then - echo "failed to create job" - exit 1 -fi - -echo "" -echo "== wait until running (max 10s) ==" -for _ in $(seq 1 20); do - STATUS="$(curl -sS "${BASE_URL}/api/v1/jobs/${JOB_ID}" | jq -r '.data.status')" - echo "status: $STATUS" - if [[ "$STATUS" == "running" || "$STATUS" == "cancel_requested" ]]; then - break - fi - if [[ "$STATUS" == "succeeded" || "$STATUS" == "cancelled" ]]; then - echo "job finished before cancel test: $STATUS" - exit 0 - fi - sleep 0.5 -done - -echo "" -echo "== cancel while worker is active ==" -CANCEL_BODY="$(curl -sS -X POST "${BASE_URL}/api/v1/jobs/${JOB_ID}/cancel" \ - -H "Content-Type: application/json" \ - -d '{"reason":"demo cancel from script"}')" -echo "$CANCEL_BODY" | jq . - -echo "" -echo "== poll until cancelled (max 20s) ==" -for _ in $(seq 1 40); do - BODY="$(curl -sS "${BASE_URL}/api/v1/jobs/${JOB_ID}")" - STATUS="$(echo "$BODY" | jq -r '.data.status')" - PHASE="$(echo "$BODY" | jq -r '.data.phase')" - SUMMARY="$(echo "$BODY" | jq -r '.data.progress.summary')" - echo "status=$STATUS phase=$PHASE summary=$SUMMARY" - if [[ "$STATUS" == "cancelled" ]]; then - echo "" - echo "ok: worker acknowledged cancel" - exit 0 - fi - sleep 0.5 -done - -echo "timeout waiting for cancelled status" -exit 1 \ No newline at end of file diff --git a/scripts/test-job-concurrency.sh b/scripts/test-job-concurrency.sh deleted file mode 100755 index 02dd3db..0000000 --- a/scripts/test-job-concurrency.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env bash -# Verify scheme B: same scope_id+target blocks concurrent runs; different targets allow parallel. -# -# Usage: -# ./scripts/test-job-concurrency.sh - -set -u - -BASE_URL="${BASE_URL:-http://127.0.0.1:8890}" -SCOPE="${SCOPE:-user}" -SCOPE_ID="${SCOPE_ID:-concurrency_demo_user}" -TEMPLATE_TYPE="${TEMPLATE_TYPE:-demo_long_task}" -TARGET_A="${TARGET_A:-building_A}" -TARGET_B="${TARGET_B:-building_B}" - -require_cmd() { - command -v "$1" >/dev/null 2>&1 || { echo "missing command: $1"; exit 1; } -} - -require_cmd curl -require_cmd jq - -create_job() { - local target="$1" - curl -sS -X POST "${BASE_URL}/api/v1/jobs" \ - -H "Content-Type: application/json" \ - -d "$(jq -n --arg scope "$SCOPE" --arg scope_id "$SCOPE_ID" --arg template "$TEMPLATE_TYPE" --arg target "$target" '{ - template_type: $template, - scope: $scope, - scope_id: $scope_id, - payload: {target: $target} - }')" -} - -echo "== configure template for scheme B ==" -PUT_BODY="$(curl -sS -X PUT "${BASE_URL}/api/v1/job/templates/${TEMPLATE_TYPE}" \ - -H "Content-Type: application/json" \ - -d "$(jq -n '{ - name: "Demo Long Task", - enabled: true, - repeatable: true, - concurrency_policy: "allow_parallel", - dedupe_keys: ["scope_id", "target"], - timeout_seconds: 600, - cancel_policy: {supported: true, mode: "cooperative", grace_seconds: 30}, - retry_policy: {max_attempts: 2, backoff_seconds: [30, 120]}, - steps: [ - {id: "prepare", name: "Prepare", worker_type: "go", timeout_seconds: 60, cancelable: true}, - {id: "execute", name: "Execute", worker_type: "go", timeout_seconds: 300, cancelable: true}, - {id: "finalize", name: "Finalize", worker_type: "go", timeout_seconds: 30, cancelable: false} - ] - }')")" -echo "$PUT_BODY" | jq . -if [[ "$(echo "$PUT_BODY" | jq -r '.code')" != "102000" ]]; then - echo "failed to upsert template" - exit 1 -fi - -echo "" -echo "== same target: first create should succeed ==" -FIRST="$(create_job "$TARGET_A")" -echo "$FIRST" | jq . -if [[ "$(echo "$FIRST" | jq -r '.code')" != "102000" ]]; then - echo "first create failed" - exit 1 -fi - -echo "" -echo "== same target: second create should fail while first is active ==" -SECOND="$(create_job "$TARGET_A")" -echo "$SECOND" | jq . -if [[ "$(echo "$SECOND" | jq -r '.code')" == "102000" ]]; then - echo "expected duplicate create to fail" - exit 1 -fi - -echo "" -echo "== different target: should succeed in parallel ==" -THIRD="$(create_job "$TARGET_B")" -echo "$THIRD" | jq . -if [[ "$(echo "$THIRD" | jq -r '.code')" != "102000" ]]; then - echo "different target create failed" - exit 1 -fi - -echo "" -echo "ok: scheme B concurrency behaves as expected" \ No newline at end of file diff --git a/start-prod.sh b/start-prod.sh new file mode 100755 index 0000000..a25f15a --- /dev/null +++ b/start-prod.sh @@ -0,0 +1,59 @@ +#!/bin/bash +set -e + +echo "=== 巡樓 Production 啟動 ===" + +# 1. 確認 nginx 不會被 default site 搶走 +echo "[1/7] 確保 nginx config..." +if [ -f /etc/nginx/sites-enabled/default ]; then + sudo rm /etc/nginx/sites-enabled/default +fi + +# 2. 起 infra (Mongo + Redis) +echo "[2/7] 啟動 Mongo + Redis..." +docker compose -f infra/docker-compose.yml --env-file infra/.env up -d + +# 3. 確保 Playwright 瀏覽器已安裝 +echo "[3/7] 確認 Playwright chromium..." +if [ ! -d /opt/haixun/.cache/ms-playwright/chromium-1228 ]; then + sudo -u haixun bash -c 'cd /opt/haixun/node-worker && npx playwright install chromium' +fi + +# 4. 清除 systemd 失敗計數(之前 crash loop 的話需要 reset) +echo "[4/7] 重置 systemd 狀態..." +for svc in haixun-gateway haixun-worker haixun-node-worker; do + sudo systemctl reset-failed "$svc" 2>/dev/null || true +done + +# 5. 啟動 systemd 服務 +echo "[5/7] 啟動 backend gateway..." +sudo systemctl start haixun-gateway +echo "[5/7] 啟動 worker..." +sudo systemctl start haixun-worker +echo "[5/7] 啟動 node worker..." +sudo systemctl start haixun-node-worker + +# 6. 初始化 DB(indexes + admin,冪等) +echo "[6/7] 初始化 DB(admin: admin@30cm.net)..." +set -a; source /opt/haixun/etc/haixun.env; set +a +sudo -E /opt/haixun/bin/tool init -f /opt/haixun/etc/gateway.prod.yaml + +# 7. 確認 nginx 執行 + reload config +echo "[7/7] 確認 nginx..." +sudo nginx -t && sudo systemctl start nginx && sudo systemctl reload nginx + +echo "" +echo "=== 啟動完成 ===" +echo " nginx: $(systemctl is-active nginx)" +echo " gateway: $(systemctl is-active haixun-gateway)" +echo " worker: $(systemctl is-active haixun-worker)" +echo " node-wkr: $(systemctl is-active haixun-node-worker)" +echo " docker: $(docker compose -f infra/docker-compose.yml ps --status running 2>/dev/null | wc -l) containers running" +echo "" +echo "健康檢查:" +echo " curl http://localhost/api/v1/health" +echo "" +echo "查看日誌:" +echo " journalctl -u haixun-gateway -n 50 --no-pager -f" +echo " journalctl -u haixun-worker -n 50 --no-pager -f" +echo " docker compose -f infra/docker-compose.yml logs -f" diff --git a/status-prod.sh b/status-prod.sh new file mode 100755 index 0000000..f0c0cc6 --- /dev/null +++ b/status-prod.sh @@ -0,0 +1,20 @@ +#!/bin/bash +echo "=== 巡樓 Production 狀態 ===" +echo "" +echo "--- Nginx ---" +systemctl status nginx --no-pager -n 5 2>&1 | head -5 +echo "" +echo "--- Gateway ---" +systemctl status haixun-gateway --no-pager -n 5 2>&1 | head -5 +echo "" +echo "--- Worker ---" +systemctl status haixun-worker --no-pager -n 5 2>&1 | head -5 +echo "" +echo "--- Node Worker ---" +systemctl status haixun-node-worker --no-pager -n 5 2>&1 | head -5 +echo "" +echo "--- Docker Infra ---" +docker compose -f /home/daniel/thread-master/infra/docker-compose.yml ps 2>&1 +echo "" +echo "--- Port 8890 ---" +ss -tlnp | grep 8890 || echo "gateway not listening" diff --git a/stop-prod.sh b/stop-prod.sh new file mode 100755 index 0000000..daca685 --- /dev/null +++ b/stop-prod.sh @@ -0,0 +1,6 @@ +#!/bin/bash +echo "=== 巡樓 Production 停止 ===" +sudo systemctl stop haixun-worker +sudo systemctl stop haixun-gateway +docker compose -f /home/daniel/thread-master/infra/docker-compose.yml down +echo "done"