From 8b0985f604358ac6f0a8695e6537dc134e8c14ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Fri, 26 Jun 2026 16:56:07 +0800 Subject: [PATCH 1/8] first commit --- Makefile | 98 --------- deploy/.env | 40 ---- deploy/.env.example | 40 ---- deploy/Dockerfile.api | 22 -- deploy/Dockerfile.node-worker | 9 - deploy/Dockerfile.web | 14 -- deploy/Dockerfile.web.static | 7 - deploy/README.md | 64 ------ deploy/config/gateway.runtime.yaml.tpl | 35 --- deploy/config/gateway.worker.runtime.yaml.tpl | 35 --- deploy/docker-compose.prod.yml | 110 ---------- deploy/docker-compose.yml | 37 ---- deploy/docker/entrypoint-api.sh | 15 -- deploy/docker/entrypoint-init.sh | 22 -- deploy/docker/entrypoint-worker.sh | 15 -- deploy/mongo/init/01-gateway-indexes.js | 31 --- deploy/nginx.conf | 55 ----- scripts/debug-opencode-raw.sh | 71 ------- scripts/dev-with-style-8d.sh | 72 ------- scripts/package-extension.sh | 24 --- scripts/prod-common.sh | 201 ------------------ scripts/prod-deps.sh | 16 -- scripts/prod-down.sh | 11 - scripts/prod-logs.sh | 8 - scripts/prod-up.sh | 23 -- scripts/prod-update.sh | 29 --- scripts/prod-wipe-data.sh | 22 -- scripts/restart-all.sh | 7 - scripts/start-all.sh | 75 ------- scripts/status-all.sh | 50 ----- scripts/stop-all.sh | 51 ----- scripts/test-job-cancel.sh | 76 ------- scripts/test-job-concurrency.sh | 87 -------- 33 files changed, 1472 deletions(-) delete mode 100644 Makefile delete mode 100644 deploy/.env delete mode 100644 deploy/.env.example delete mode 100644 deploy/Dockerfile.api delete mode 100644 deploy/Dockerfile.node-worker delete mode 100644 deploy/Dockerfile.web delete mode 100644 deploy/Dockerfile.web.static delete mode 100644 deploy/README.md delete mode 100644 deploy/config/gateway.runtime.yaml.tpl delete mode 100644 deploy/config/gateway.worker.runtime.yaml.tpl delete mode 100644 deploy/docker-compose.prod.yml delete mode 100644 deploy/docker-compose.yml delete mode 100644 deploy/docker/entrypoint-api.sh delete mode 100644 deploy/docker/entrypoint-init.sh delete mode 100644 deploy/docker/entrypoint-worker.sh delete mode 100644 deploy/mongo/init/01-gateway-indexes.js delete mode 100644 deploy/nginx.conf delete mode 100755 scripts/debug-opencode-raw.sh delete mode 100644 scripts/dev-with-style-8d.sh delete mode 100755 scripts/package-extension.sh delete mode 100755 scripts/prod-common.sh delete mode 100755 scripts/prod-deps.sh delete mode 100755 scripts/prod-down.sh delete mode 100755 scripts/prod-logs.sh delete mode 100755 scripts/prod-up.sh delete mode 100755 scripts/prod-update.sh delete mode 100755 scripts/prod-wipe-data.sh delete mode 100755 scripts/restart-all.sh delete mode 100755 scripts/start-all.sh delete mode 100755 scripts/status-all.sh delete mode 100755 scripts/stop-all.sh delete mode 100755 scripts/test-job-cancel.sh delete mode 100755 scripts/test-job-concurrency.sh diff --git a/Makefile b/Makefile deleted file mode 100644 index 8570fa2..0000000 --- a/Makefile +++ /dev/null @@ -1,98 +0,0 @@ -GO ?= go -GOFMT ?= gofmt -GOCTL ?= goctl -GO_ZERO_STYLE := go_zero -API_ENTRY := ./generate/api/gateway.api -GOFILES := $(shell find . -name '*.go') - -.DEFAULT_GOAL := help - -help: ## 顯示可用指令 - @echo "Haixun Backend" - @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) - -gen-api: tools ## 由 .api 生成 handler / logic / types - $(GOCTL) api go -api $(API_ENTRY) -dir . -style $(GO_ZERO_STYLE) -home generate/goctl - -fmt: ## gofmt + goimports - $(GOFMT) -s -w $(GOFILES) - @command -v goimports >/dev/null 2>&1 && goimports -w . || true - -test: ## 執行測試 - $(GO) test ./... - -run: ## 啟動 API(前景) - $(GO) run ./gateway.go -f etc/gateway.yaml - -dev-all: ## 一鍵啟動 Mongo/Redis + API + 前端 + 8D worker(背景) - bash scripts/start-all.sh - -stop-all: ## 一鍵停止全部開發服務 - bash scripts/stop-all.sh - -restart-all: ## 一鍵重啟全部開發服務 - bash scripts/restart-all.sh - -status-all: ## 查看全部開發服務狀態 - bash scripts/status-all.sh - -stop: stop-all ## 同 stop-all - -restart: restart-all ## 同 restart-all - -dev-8d: ## 一鍵啟動 API + Node 8D worker(前景,Ctrl+C 結束) - bash scripts/dev-with-style-8d.sh - -CONFIG ?= etc/gateway.yaml -INIT_TENANT ?= default -INIT_EMAIL ?= admin@30cm.net -INIT_PASSWORD ?= Fafafa54088 - -tool-init: ## 初始化 Mongo indexes、預設權限與 admin 帳號 - $(GO) run ./cmd/tool init -f $(CONFIG) -tenant $(INIT_TENANT) -email $(INIT_EMAIL) -password '$(INIT_PASSWORD)' - -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 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/config/gateway.worker.runtime.yaml.tpl b/deploy/config/gateway.worker.runtime.yaml.tpl deleted file mode 100644 index c301ec5..0000000 --- a/deploy/config/gateway.worker.runtime.yaml.tpl +++ /dev/null @@ -1,35 +0,0 @@ -Name: haixun-worker -Host: 0.0.0.0 -Port: 8891 -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: true - WorkerType: go - -JobScheduler: - Enabled: false - IntervalSeconds: 60 - -JobReaper: - Enabled: false - 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/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 -- 2.40.1 From 3342f7a344b76769a78a031434180a012c4ba4ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Fri, 26 Jun 2026 20:09:36 +0800 Subject: [PATCH 2/8] fix floder --- .DS_Store | Bin 0 -> 8196 bytes backend/.DS_Store | Bin 0 -> 8196 bytes README.md => backend/README.md | 0 {cmd => backend/cmd}/tool/main.go | 0 {cmd => backend/cmd}/worker/main.go | 0 {docs => backend/docs}/job-system-plan.md | 0 {docs => backend/docs}/scan-placement-plan.md | 0 {etc => backend/etc}/gateway.dev.example.yaml | 0 {etc => backend/etc}/gateway.prod.yaml | 0 {etc => backend/etc}/gateway.worker.yaml | 0 {etc => backend/etc}/gateway.yaml | 0 gateway.go => backend/gateway.go | 0 {generate => backend/generate}/api/ai.api | 0 {generate => backend/generate}/api/auth.api | 0 {generate => backend/generate}/api/brand.api | 0 {generate => backend/generate}/api/common.api | 0 .../generate}/api/copy_mission.api | 0 {generate => backend/generate}/api/gateway.api | 0 {generate => backend/generate}/api/job.api | 0 {generate => backend/generate}/api/member.api | 0 {generate => backend/generate}/api/normal.api | 0 .../generate}/api/permission.api | 0 {generate => backend/generate}/api/persona.api | 0 .../generate}/api/placement_topic.api | 0 {generate => backend/generate}/api/setting.api | 0 .../generate}/api/threads_account.api | 0 .../generate}/api/worker_internal.api | 0 .../generate}/goctl/api/handler.tpl | 0 go.mod => backend/go.mod | 0 go.sum => backend/go.sum | 0 .../internal}/bootstrap/admin.go | 0 .../internal}/bootstrap/admin_test.go | 0 {internal => backend/internal}/bootstrap/init.go | 0 {internal => backend/internal}/config/config.go | 0 .../internal}/handler/ai/chat_handler.go | 0 .../internal}/handler/ai/chat_stream_handler.go | 0 .../handler/ai/islander_chat_stream_handler.go | 0 .../ai/list_ai_provider_models_handler.go | 0 .../handler/ai/list_ai_providers_handler.go | 0 .../internal}/handler/auth/login_handler.go | 0 .../internal}/handler/auth/logout_handler.go | 0 .../internal}/handler/auth/refresh_handler.go | 0 .../internal}/handler/auth/register_handler.go | 0 .../handler/brand/create_brand_handler.go | 0 .../brand/create_brand_product_handler.go | 0 .../handler/brand/delete_brand_handler.go | 0 .../brand/delete_brand_product_handler.go | 0 .../brand/expand_knowledge_graph_handler.go | 0 .../generate_brand_content_matrix_handler.go | 0 .../brand/generate_outreach_drafts_handler.go | 0 .../brand/get_brand_content_matrix_handler.go | 0 .../internal}/handler/brand/get_brand_handler.go | 0 .../brand/get_brand_scan_schedule_handler.go | 0 .../handler/brand/get_knowledge_graph_handler.go | 0 .../handler/brand/list_brand_products_handler.go | 0 .../brand/list_brand_scan_posts_handler.go | 0 .../handler/brand/list_brands_handler.go | 0 .../brand/patch_knowledge_graph_nodes_handler.go | 0 .../brand/patch_scan_post_outreach_handler.go | 0 .../brand/publish_outreach_draft_handler.go | 0 .../brand/start_brand_scan_job_handler.go | 0 .../handler/brand/update_brand_handler.go | 0 .../brand/update_brand_product_handler.go | 0 .../brand/upsert_brand_scan_schedule_handler.go | 0 .../copy_mission/create_copy_mission_handler.go | 0 .../copy_mission/delete_copy_mission_handler.go | 0 .../generate_copy_mission_matrix_handler.go | 0 .../copy_mission/get_copy_mission_handler.go | 0 .../get_copy_mission_scan_schedule_handler.go | 0 .../copy_mission/inspire_copy_mission_handler.go | 0 .../list_copy_mission_copy_drafts_handler.go | 0 .../list_copy_mission_scan_posts_handler.go | 0 .../copy_mission/list_copy_missions_handler.go | 0 .../start_copy_mission_analyze_job_handler.go | 0 .../start_copy_mission_copy_draft_job_handler.go | 0 .../start_copy_mission_matrix_job_handler.go | 0 .../start_copy_mission_scan_job_handler.go | 0 .../copy_mission/update_copy_mission_handler.go | 0 .../upsert_copy_mission_scan_schedule_handler.go | 0 .../handler/job/ack_worker_job_cancel_handler.go | 0 .../job/analyze_style8_d_from_worker_handler.go | 0 .../internal}/handler/job/cancel_job_handler.go | 0 .../job/check_worker_job_cancel_handler.go | 0 .../handler/job/claim_worker_job_handler.go | 0 .../handler/job/complete_worker_job_handler.go | 0 .../internal}/handler/job/create_job_handler.go | 0 .../handler/job/create_job_schedule_handler.go | 0 .../handler/job/delete_job_schedule_handler.go | 0 .../handler/job/disable_job_schedule_handler.go | 0 .../handler/job/enable_job_schedule_handler.go | 0 .../handler/job/fail_worker_job_handler.go | 0 .../internal}/handler/job/get_job_handler.go | 0 .../handler/job/get_job_template_handler.go | 0 ...get_worker_threads_account_session_handler.go | 0 .../handler/job/list_job_events_handler.go | 0 .../handler/job/list_job_schedules_handler.go | 0 .../handler/job/list_job_templates_handler.go | 0 .../internal}/handler/job/list_jobs_handler.go | 0 .../job/refresh_worker_job_lock_handler.go | 0 .../internal}/handler/job/retry_job_handler.go | 0 ..._persona_style_profile_from_worker_handler.go | 0 .../handler/job/update_job_schedule_handler.go | 0 .../job/update_worker_job_progress_handler.go | 0 .../handler/job/upsert_job_template_handler.go | 0 .../handler/member/get_member_me_handler.go | 0 .../get_member_placement_settings_handler.go | 0 .../handler/member/update_member_me_handler.go | 0 .../update_member_placement_settings_handler.go | 0 .../internal}/handler/normal/health_handler.go | 0 .../permission/get_me_permissions_handler.go | 0 .../permission/get_permission_catalog_handler.go | 0 .../handler/persona/create_persona_handler.go | 0 .../handler/persona/delete_persona_handler.go | 0 .../generate_persona_copy_draft_handler.go | 0 .../handler/persona/get_persona_handler.go | 0 .../persona/list_persona_copy_drafts_handler.go | 0 .../list_persona_viral_scan_posts_handler.go | 0 .../handler/persona/list_personas_handler.go | 0 .../publish_persona_copy_draft_handler.go | 0 .../start_persona_style_analysis_handler.go | 0 .../start_persona_viral_scan_job_handler.go | 0 .../persona/update_persona_copy_draft_handler.go | 0 .../handler/persona/update_persona_handler.go | 0 ..._delete_placement_topic_scan_posts_handler.go | 0 .../create_placement_topic_handler.go | 0 .../delete_placement_topic_handler.go | 0 .../delete_placement_topic_scan_post_handler.go | 0 .../expand_placement_topic_graph_handler.go | 0 ...ate_placement_topic_content_matrix_handler.go | 0 ...te_placement_topic_outreach_drafts_handler.go | 0 ...get_placement_topic_content_matrix_handler.go | 0 .../get_placement_topic_graph_handler.go | 0 .../get_placement_topic_handler.go | 0 .../get_placement_topic_scan_schedule_handler.go | 0 .../list_placement_topic_scan_posts_handler.go | 0 .../list_placement_topics_handler.go | 0 .../patch_placement_topic_graph_nodes_handler.go | 0 ...placement_topic_scan_post_outreach_handler.go | 0 ...ish_placement_topic_outreach_draft_handler.go | 0 .../start_placement_topic_scan_job_handler.go | 0 .../update_placement_topic_handler.go | 0 ...sert_placement_topic_scan_schedule_handler.go | 0 {internal => backend/internal}/handler/routes.go | 0 .../handler/setting/delete_setting_handler.go | 0 .../handler/setting/get_setting_handler.go | 0 .../handler/setting/list_settings_handler.go | 0 .../handler/setting/upsert_setting_handler.go | 0 .../activate_threads_account_handler.go | 0 .../create_threads_account_handler.go | 0 .../get_threads_account_ai_settings_handler.go | 0 .../get_threads_account_connection_handler.go | 0 .../get_threads_account_handler.go | 0 .../import_threads_account_session_handler.go | 0 .../list_threads_accounts_handler.go | 0 ...update_threads_account_ai_settings_handler.go | 0 .../update_threads_account_connection_handler.go | 0 .../update_threads_account_handler.go | 0 .../internal}/library/authctx/context.go | 0 .../internal}/library/brave/breaker.go | 0 .../internal}/library/brave/client.go | 0 .../internal}/library/clock/clock.go | 0 .../internal}/library/clock/clock_test.go | 0 .../internal}/library/errors/code/types.go | 0 .../internal}/library/errors/errors.go | 0 .../internal}/library/exa/client.go | 0 .../internal}/library/exa/client_test.go | 0 .../internal}/library/knowledge/bootstrap.go | 0 .../library/knowledge/bootstrap_test.go | 0 .../internal}/library/knowledge/brave_collect.go | 0 .../library/knowledge/brave_collect_test.go | 0 .../library/knowledge/expand_strategy.go | 0 .../library/knowledge/expand_strategy_test.go | 0 .../internal}/library/knowledge/graph.go | 0 .../internal}/library/knowledge/patrol_input.go | 0 .../internal}/library/knowledge/patrol_phrase.go | 0 .../library/knowledge/patrol_phrase_test.go | 0 .../internal}/library/knowledge/patrol_rank.go | 0 .../library/knowledge/patrol_rank_test.go | 0 .../library/knowledge/patrol_resolve.go | 0 .../library/knowledge/patrol_resolve_test.go | 0 .../internal}/library/knowledge/patrol_tags.go | 0 .../library/knowledge/patrol_tags_test.go | 0 .../internal}/library/knowledge/quality.go | 0 .../internal}/library/knowledge/quality_test.go | 0 .../internal}/library/knowledge/queries.go | 0 .../internal}/library/knowledge/queries_test.go | 0 .../internal}/library/knowledge/synth.go | 0 .../internal}/library/knowledge/synth_test.go | 0 .../internal}/library/matrix/copy_generate.go | 0 .../internal}/library/matrix/generate.go | 0 .../internal}/library/matrix/samples.go | 0 .../internal}/library/mongo/brand_scope.go | 0 .../internal}/library/mongo/client.go | 0 .../internal}/library/mongo/ensure_index.go | 0 .../internal}/library/outreach/generate.go | 0 .../internal}/library/outreach/generate_test.go | 0 .../internal}/library/permmatch/match.go | 0 .../internal}/library/permmatch/match_test.go | 0 .../internal}/library/placement/ai_generate.go | 0 .../internal}/library/placement/context.go | 0 .../library/placement/context_prompt.go | 0 .../library/placement/context_prompt_test.go | 0 .../internal}/library/placement/crawler_exec.go | 0 .../library/placement/crawler_exec_test.go | 0 .../library/placement/crawler_polite.go | 0 .../internal}/library/placement/discover.go | 0 .../library/placement/discover_route.go | 0 .../library/placement/discover_route_test.go | 0 .../internal}/library/placement/dual_track.go | 0 .../library/placement/effective_strategy.go | 0 .../library/placement/effective_strategy_test.go | 0 .../internal}/library/placement/exclusion.go | 0 .../internal}/library/placement/filter.go | 0 .../internal}/library/placement/filter_test.go | 0 .../internal}/library/placement/parse_threads.go | 0 .../library/placement/patrol_queries.go | 0 .../library/placement/patrol_queries_test.go | 0 .../library/placement/product_catalog.go | 0 .../library/placement/product_context.go | 0 .../internal}/library/placement/product_match.go | 0 .../internal}/library/placement/query_build.go | 0 .../internal}/library/placement/recency.go | 0 .../internal}/library/placement/research_map.go | 0 .../library/placement/research_map_test.go | 0 .../library/placement/scrape_replies.go | 0 .../library/placement/scrape_replies_test.go | 0 .../internal}/library/placement/source_mode.go | 0 .../library/placement/source_mode_test.go | 0 .../internal}/library/placement/threads_api.go | 0 .../internal}/library/prompt/compose.go | 0 .../internal}/library/prompt/compose_test.go | 0 .../internal}/library/prompt/errors.go | 0 .../library/prompt/files/ai.chat.system.md | 0 .../library/prompt/files/ai.islander.system.md | 0 .../prompt/files/knowledge_graph.queries.json | 0 .../prompt/files/knowledge_graph.supplemental.md | 0 .../prompt/files/knowledge_graph.system.md | 0 .../library/prompt/files/knowledge_graph.user.md | 0 .../prompt/files/knowledge_graph_llm.system.md | 0 .../prompt/files/knowledge_graph_llm.user.md | 0 .../library/prompt/files/matrix_copy.system.md | 0 .../library/prompt/files/matrix_copy.user.md | 0 .../prompt/files/matrix_placement.system.md | 0 .../prompt/files/matrix_placement.user.md | 0 .../prompt/files/outreach_placement.system.md | 0 .../prompt/files/outreach_placement.user.md | 0 .../internal}/library/prompt/files/overlay.md | 0 .../library/prompt/files/style8d.schema.md | 0 .../library/prompt/files/style8d.system.md | 0 .../internal}/library/prompt/registry.go | 0 .../internal}/library/redact/redact.go | 0 .../internal}/library/redact/redact_test.go | 0 .../internal}/library/redis/client.go | 0 .../internal}/library/style8d/analyze.go | 0 .../internal}/library/style8d/analyze_test.go | 0 .../internal}/library/style8d/prompt.go | 0 .../internal}/library/style8d/prompt_test.go | 0 .../internal}/library/threadsapi/client.go | 0 .../internal}/library/threadsapi/publish.go | 0 .../internal}/library/threadsapi/replies.go | 0 .../internal}/library/threadspost/limits.go | 0 .../internal}/library/threadspost/limits_test.go | 0 .../internal}/library/validate/validate.go | 0 .../internal}/library/viral/analyze_viral.go | 0 .../internal}/library/viral/discover.go | 0 .../internal}/library/viral/discover_accounts.go | 0 .../library/viral/discover_accounts_test.go | 0 .../library/viral/discover_budget_test.go | 0 .../library/viral/discover_graceful_test.go | 0 .../internal}/library/viral/enrich_accounts.go | 0 .../internal}/library/viral/flexible_json.go | 0 .../internal}/library/viral/mission_inspire.go | 0 .../library/viral/mission_inspire_collect.go | 0 .../library/viral/mission_inspire_test.go | 0 .../library/viral/mission_research_map.go | 0 .../library/viral/mission_research_map_test.go | 0 .../internal}/library/viral/quality.go | 0 .../internal}/library/viral/quality_test.go | 0 .../library/viral/reference_accounts.go | 0 .../viral/reference_accounts_fallback_test.go | 0 .../library/viral/reference_accounts_test.go | 0 .../internal}/library/viral/replicate.go | 0 .../internal}/library/viral/research_map.go | 0 .../internal}/library/viral/score.go | 0 .../internal}/library/viral/search_tags.go | 0 .../internal}/library/viral/search_tags_test.go | 0 .../internal}/library/viral/tags.go | 0 .../internal}/library/viral/tags_test.go | 0 .../internal}/library/websearch/client.go | 0 {internal => backend/internal}/logic/ai/actor.go | 0 .../internal}/logic/ai/chat_logic.go | 0 .../internal}/logic/ai/chat_stream_logic.go | 0 .../internal}/logic/ai/credential.go | 0 .../logic/ai/islander_chat_stream_logic.go | 0 .../logic/ai/list_ai_provider_models_logic.go | 0 .../logic/ai/list_ai_providers_logic.go | 0 .../internal}/logic/ai/mapper.go | 0 .../internal}/logic/auth/login_logic.go | 0 .../internal}/logic/auth/logout_logic.go | 0 .../internal}/logic/auth/mapper.go | 0 .../internal}/logic/auth/refresh_logic.go | 0 .../internal}/logic/auth/register_logic.go | 0 .../internal}/logic/authz/admin.go | 0 .../internal}/logic/brand/actor.go | 0 .../internal}/logic/brand/create_brand_logic.go | 0 .../logic/brand/create_brand_product_logic.go | 0 .../internal}/logic/brand/delete_brand_logic.go | 0 .../logic/brand/delete_brand_product_logic.go | 0 .../logic/brand/expand_knowledge_graph_logic.go | 0 .../brand/generate_brand_content_matrix_logic.go | 0 .../brand/generate_outreach_drafts_logic.go | 0 .../brand/get_brand_content_matrix_logic.go | 0 .../internal}/logic/brand/get_brand_logic.go | 0 .../logic/brand/get_brand_scan_schedule_logic.go | 0 .../logic/brand/get_knowledge_graph_logic.go | 0 .../logic/brand/list_brand_products_logic.go | 0 .../logic/brand/list_brand_scan_posts_logic.go | 0 .../internal}/logic/brand/list_brands_logic.go | 0 .../internal}/logic/brand/mapper.go | 0 .../brand/patch_knowledge_graph_nodes_logic.go | 0 .../brand/patch_scan_post_outreach_logic.go | 0 .../logic/brand/publish_outreach_draft_logic.go | 0 .../logic/brand/start_brand_scan_job_logic.go | 0 .../internal}/logic/brand/update_brand_logic.go | 0 .../logic/brand/update_brand_product_logic.go | 0 .../brand/upsert_brand_scan_schedule_logic.go | 0 .../internal}/logic/copy_mission/actor.go | 0 .../copy_mission/create_copy_mission_logic.go | 0 .../copy_mission/delete_copy_mission_logic.go | 0 .../generate_copy_mission_matrix_logic.go | 0 .../logic/copy_mission/get_copy_mission_logic.go | 0 .../get_copy_mission_scan_schedule_logic.go | 0 .../copy_mission/inspire_copy_mission_logic.go | 0 .../list_copy_mission_copy_drafts_logic.go | 0 .../list_copy_mission_scan_posts_logic.go | 0 .../copy_mission/list_copy_missions_logic.go | 0 .../internal}/logic/copy_mission/mapper.go | 0 .../logic/copy_mission/schedule_helper.go | 0 .../start_copy_mission_analyze_job_logic.go | 0 .../start_copy_mission_copy_draft_job_logic.go | 0 .../start_copy_mission_matrix_job_logic.go | 0 .../start_copy_mission_scan_job_logic.go | 0 .../copy_mission/update_copy_mission_logic.go | 0 .../upsert_copy_mission_scan_schedule_logic.go | 0 .../logic/job/ack_worker_job_cancel_logic.go | 0 .../internal}/logic/job/actor.go | 0 .../job/analyze_style8_d_from_worker_logic.go | 0 .../internal}/logic/job/cancel_job_logic.go | 0 .../logic/job/check_worker_job_cancel_logic.go | 0 .../logic/job/claim_worker_job_logic.go | 0 .../logic/job/complete_worker_job_logic.go | 0 .../internal}/logic/job/create_job_logic.go | 0 .../logic/job/create_job_schedule_logic.go | 0 .../logic/job/delete_job_schedule_logic.go | 0 .../logic/job/disable_job_schedule_logic.go | 0 .../logic/job/enable_job_schedule_logic.go | 0 .../internal}/logic/job/fail_worker_job_logic.go | 0 .../internal}/logic/job/get_job_logic.go | 0 .../logic/job/get_job_template_logic.go | 0 .../get_worker_threads_account_session_logic.go | 0 .../internal}/logic/job/list_job_events_logic.go | 0 .../logic/job/list_job_schedules_logic.go | 0 .../logic/job/list_job_templates_logic.go | 0 .../internal}/logic/job/list_jobs_logic.go | 0 .../internal}/logic/job/mapper.go | 0 .../internal}/logic/job/ownership.go | 0 .../internal}/logic/job/ownership_test.go | 0 .../logic/job/refresh_worker_job_lock_logic.go | 0 .../internal}/logic/job/retry_job_logic.go | 0 ...re_persona_style_profile_from_worker_logic.go | 0 .../logic/job/update_job_schedule_logic.go | 0 .../job/update_worker_job_progress_logic.go | 0 .../logic/job/upsert_job_template_logic.go | 0 .../internal}/logic/job/worker_helpers.go | 0 .../logic/member/get_member_me_logic.go | 0 .../get_member_placement_settings_logic.go | 0 .../internal}/logic/member/mapper.go | 0 .../logic/member/update_member_me_logic.go | 0 .../update_member_placement_settings_logic.go | 0 .../internal}/logic/normal/health_logic.go | 0 .../logic/permission/get_me_permissions_logic.go | 0 .../permission/get_permission_catalog_logic.go | 0 .../internal}/logic/permission/mapper.go | 0 .../internal}/logic/persona/actor.go | 0 .../logic/persona/create_persona_logic.go | 0 .../logic/persona/delete_persona_logic.go | 0 .../persona/generate_persona_copy_draft_logic.go | 0 .../internal}/logic/persona/get_persona_logic.go | 0 .../persona/list_persona_copy_drafts_logic.go | 0 .../list_persona_viral_scan_posts_logic.go | 0 .../logic/persona/list_personas_logic.go | 0 .../internal}/logic/persona/mapper.go | 0 .../persona/publish_persona_copy_draft_logic.go | 0 .../start_persona_style_analysis_logic.go | 0 .../start_persona_viral_scan_job_logic.go | 0 .../persona/update_persona_copy_draft_logic.go | 0 .../logic/persona/update_persona_logic.go | 0 .../internal}/logic/placement_topic/actor.go | 0 ...ch_delete_placement_topic_scan_posts_logic.go | 0 .../create_placement_topic_logic.go | 0 .../delete_placement_topic_logic.go | 0 .../delete_placement_topic_scan_post_logic.go | 0 .../expand_placement_topic_graph_logic.go | 0 ...erate_placement_topic_content_matrix_logic.go | 0 ...rate_placement_topic_outreach_drafts_logic.go | 0 .../get_placement_topic_content_matrix_logic.go | 0 .../get_placement_topic_graph_logic.go | 0 .../placement_topic/get_placement_topic_logic.go | 0 .../get_placement_topic_scan_schedule_logic.go | 0 .../list_placement_topic_scan_posts_logic.go | 0 .../list_placement_topics_logic.go | 0 .../internal}/logic/placement_topic/mapper.go | 0 .../patch_placement_topic_graph_nodes_logic.go | 0 ...h_placement_topic_scan_post_outreach_logic.go | 0 ...blish_placement_topic_outreach_draft_logic.go | 0 .../internal}/logic/placement_topic/scope.go | 0 .../start_placement_topic_scan_job_logic.go | 0 .../update_placement_topic_logic.go | 0 ...upsert_placement_topic_scan_schedule_logic.go | 0 .../logic/setting/delete_setting_logic.go | 0 .../internal}/logic/setting/get_setting_logic.go | 0 .../logic/setting/list_settings_logic.go | 0 .../internal}/logic/setting/mapper.go | 0 .../logic/setting/upsert_setting_logic.go | 0 .../activate_threads_account_logic.go | 0 .../internal}/logic/threads_account/actor.go | 0 .../create_threads_account_logic.go | 0 .../get_threads_account_ai_settings_logic.go | 0 .../get_threads_account_connection_logic.go | 0 .../threads_account/get_threads_account_logic.go | 0 .../import_threads_account_session_logic.go | 0 .../list_threads_accounts_logic.go | 0 .../internal}/logic/threads_account/mapper.go | 0 .../update_threads_account_ai_settings_logic.go | 0 .../update_threads_account_connection_logic.go | 0 .../update_threads_account_logic.go | 0 .../internal}/middleware/auth.go | 0 .../internal}/middleware/auth_middleware.go | 0 .../internal}/middleware/auth_test.go | 0 .../internal}/middleware/authjwt_middleware.go | 0 .../middleware/memberauth_middleware.go | 0 .../middleware/permissionrbac_middleware.go | 0 .../middleware/workersecret_middleware.go | 0 .../internal}/model/ai/domain/enum/provider.go | 0 .../internal}/model/ai/domain/usecase/ai.go | 0 .../model/ai/provider/openai_compatible.go | 0 .../model/ai/provider/openai_compatible_test.go | 0 .../internal}/model/ai/usecase/provider_map.go | 0 .../internal}/model/ai/usecase/usecase.go | 0 .../model/auth/domain/repository/token_revoke.go | 0 .../internal}/model/auth/domain/usecase/token.go | 0 .../model/auth/repository/token_revoke_redis.go | 0 .../internal}/model/auth/usecase/token.go | 0 .../internal}/model/brand/domain/entity/brand.go | 0 .../model/brand/domain/entity/product.go | 0 .../model/brand/domain/entity/research_map.go | 0 .../model/brand/domain/repository/repository.go | 0 .../model/brand/domain/usecase/usecase.go | 0 .../internal}/model/brand/repository/mongo.go | 0 .../internal}/model/brand/usecase/products.go | 0 .../internal}/model/brand/usecase/usecase.go | 0 .../model/content_matrix/domain/entity/matrix.go | 0 .../domain/repository/repository.go | 0 .../content_matrix/domain/usecase/usecase.go | 0 .../model/content_matrix/repository/mongo.go | 0 .../model/content_matrix/usecase/usecase.go | 0 .../model/copy_draft/domain/entity/draft.go | 0 .../copy_draft/domain/repository/repository.go | 0 .../model/copy_draft/domain/usecase/usecase.go | 0 .../model/copy_draft/repository/mongo.go | 0 .../model/copy_draft/usecase/usecase.go | 0 .../model/copy_mission/domain/entity/mission.go | 0 .../copy_mission/domain/repository/repository.go | 0 .../model/copy_mission/domain/usecase/usecase.go | 0 .../model/copy_mission/repository/mongo.go | 0 .../model/copy_mission/usecase/usecase.go | 0 .../internal}/model/job/cron/next_run.go | 0 .../internal}/model/job/cron/next_run_test.go | 0 .../internal}/model/job/domain/entity/event.go | 0 .../internal}/model/job/domain/entity/run.go | 0 .../model/job/domain/entity/schedule.go | 0 .../model/job/domain/entity/template.go | 0 .../internal}/model/job/domain/enum/status.go | 0 .../model/job/domain/repository/event.go | 0 .../model/job/domain/repository/queue.go | 0 .../internal}/model/job/domain/repository/run.go | 0 .../model/job/domain/repository/schedule.go | 0 .../model/job/domain/repository/template.go | 0 .../internal}/model/job/domain/usecase/job.go | 0 .../model/job/repository/mongo_event.go | 0 .../internal}/model/job/repository/mongo_run.go | 0 .../model/job/repository/mongo_schedule.go | 0 .../model/job/repository/mongo_template.go | 0 .../model/job/repository/redis_queue.go | 0 .../model/job/repository/redis_queue_test.go | 0 .../internal}/model/job/resume/resume.go | 0 .../internal}/model/job/resume/resume_test.go | 0 .../internal}/model/job/usecase/cancel_test.go | 0 .../internal}/model/job/usecase/dedupe_test.go | 0 .../internal}/model/job/usecase/helpers.go | 0 .../internal}/model/job/usecase/maintenance.go | 0 .../model/job/usecase/maintenance_test.go | 0 .../model/job/usecase/refresh_lock_test.go | 0 .../model/job/usecase/retry_resume_test.go | 0 .../internal}/model/job/usecase/retry_test.go | 0 .../internal}/model/job/usecase/schedule.go | 0 .../model/job/usecase/schedule_payload.go | 0 .../model/job/usecase/schedule_payload_test.go | 0 .../internal}/model/job/usecase/schedule_test.go | 0 .../internal}/model/job/usecase/step_resume.go | 0 .../internal}/model/job/usecase/template.go | 0 .../internal}/model/job/usecase/template_test.go | 0 .../internal}/model/job/usecase/test_mocks.go | 0 .../internal}/model/job/usecase/usecase.go | 0 .../model/knowledge_graph/domain/entity/graph.go | 0 .../domain/repository/repository.go | 0 .../knowledge_graph/domain/usecase/usecase.go | 0 .../model/knowledge_graph/repository/mongo.go | 0 .../model/knowledge_graph/usecase/usecase.go | 0 .../model/member/domain/entity/member.go | 0 .../model/member/domain/repository/member.go | 0 .../model/member/domain/usecase/member.go | 0 .../internal}/model/member/repository/mongo.go | 0 .../internal}/model/member/usecase/usecase.go | 0 .../model/outreach_draft/domain/entity/draft.go | 0 .../domain/repository/repository.go | 0 .../outreach_draft/domain/usecase/usecase.go | 0 .../model/outreach_draft/repository/mongo.go | 0 .../model/outreach_draft/usecase/errors.go | 0 .../model/outreach_draft/usecase/usecase.go | 0 .../model/permission/domain/entity/permission.go | 0 .../permission/domain/repository/permission.go | 0 .../permission/domain/usecase/permission.go | 0 .../permission/repository/mongo_permission.go | 0 .../repository/mongo_role_permission.go | 0 .../model/permission/repository/object_id.go | 0 .../model/permission/usecase/usecase.go | 0 .../model/permission/usecase/usecase_test.go | 0 .../persona/domain/entity/copy_research_map.go | 0 .../model/persona/domain/entity/persona.go | 0 .../persona/domain/repository/repository.go | 0 .../model/persona/domain/usecase/usecase.go | 0 .../internal}/model/persona/repository/mongo.go | 0 .../internal}/model/persona/usecase/usecase.go | 0 .../model/placement/usecase/settings.go | 0 .../model/placement_topic/domain/entity/topic.go | 0 .../domain/repository/repository.go | 0 .../placement_topic/domain/usecase/usecase.go | 0 .../model/placement_topic/repository/mongo.go | 0 .../model/placement_topic/usecase/usecase.go | 0 .../scan_post/domain/entity/outreach_patch.go | 0 .../model/scan_post/domain/entity/post.go | 0 .../scan_post/domain/repository/repository.go | 0 .../model/scan_post/domain/usecase/usecase.go | 0 .../model/scan_post/repository/mongo.go | 0 .../repository/mongo_priority_filter_test.go | 0 .../repository/mongo_topic_scope_test.go | 0 .../internal}/model/scan_post/usecase/errors.go | 0 .../internal}/model/scan_post/usecase/usecase.go | 0 .../model/setting/domain/entity/setting.go | 0 .../model/setting/domain/repository/setting.go | 0 .../model/setting/domain/usecase/setting.go | 0 .../internal}/model/setting/repository/mongo.go | 0 .../internal}/model/setting/usecase/usecase.go | 0 .../threads_account/domain/entity/account.go | 0 .../threads_account/domain/entity/secrets.go | 0 .../domain/repository/repository.go | 0 .../threads_account/domain/usecase/usecase.go | 0 .../model/threads_account/repository/mongo.go | 0 .../threads_account/repository/secrets_mongo.go | 0 .../threads_account/usecase/ai_credentials.go | 0 .../threads_account/usecase/placement_context.go | 0 .../usecase/placement_context_test.go | 0 .../usecase/publish_credentials.go | 0 .../model/threads_account/usecase/usecase.go | 0 .../internal}/response/request.go | 0 .../internal}/response/response.go | 0 .../internal}/svc/service_context.go | 0 {internal => backend/internal}/types/types.go | 0 .../internal}/worker/job/analyze_copy_mission.go | 0 .../internal}/worker/job/crawler_search.go | 0 .../internal}/worker/job/expand_graph.go | 0 .../internal}/worker/job/generate_copy_draft.go | 0 .../internal}/worker/job/generate_copy_matrix.go | 0 .../internal}/worker/job/payload_actor.go | 0 .../internal}/worker/job/placement_scope.go | 0 .../internal}/worker/job/reaper.go | 0 .../internal}/worker/job/runner.go | 0 .../internal}/worker/job/runner_test.go | 0 .../internal}/worker/job/scan_placement.go | 0 .../internal}/worker/job/scan_viral.go | 0 .../internal}/worker/job/scheduler.go | 0 {worker => backend/worker}/package.json | 0 {worker => backend/worker}/style-8d-worker.ts | 0 .../worker}/threads-keyword-search-cli.ts | 0 .../worker}/threads-keyword-search.ts | 0 .../worker}/threads-profile-scraper.ts | 0 {web => frontend}/.gitignore | 0 {web => frontend}/index.html | 0 {web => frontend}/package-lock.json | 0 {web => frontend}/package.json | 0 .../public/downloads/haixun-threads-sync.zip | Bin .../public/illustrations/island/airplane.svg | 0 .../public/illustrations/island/bell.svg | 0 .../public/illustrations/island/cloud.svg | 0 .../illustrations/island/desert-island.svg | 0 .../public/illustrations/island/herb.svg | 0 .../public/illustrations/island/hut.svg | 0 .../illustrations/island/magnifying-glass.svg | 0 .../public/illustrations/island/passport.svg | 0 .../public/illustrations/island/sun.svg | 0 .../public/illustrations/island/water-wave.svg | 0 {web => frontend}/src/App.tsx | 0 {web => frontend}/src/api/client.ts | 0 {web => frontend}/src/auth/AuthContext.tsx | 0 {web => frontend}/src/components/AcIcon.tsx | 0 .../src/components/AccountAiSettings.tsx | 0 .../src/components/AccountConnectionMode.tsx | 0 .../components/AccountDisplayNameSettings.tsx | 0 .../src/components/AccountSwitcher.tsx | 0 {web => frontend}/src/components/AdminRoute.tsx | 0 .../src/components/AppBrandLink.tsx | 0 {web => frontend}/src/components/AppSidebar.tsx | 0 {web => frontend}/src/components/AuthDecor.tsx | 0 {web => frontend}/src/components/AuthShell.tsx | 0 .../src/components/BrandProductPicker.tsx | 0 .../src/components/BrandProductsPanel.tsx | 0 .../src/components/BrandSwitcher.tsx | 0 {web => frontend}/src/components/CopyFlowNav.tsx | 0 .../src/components/CopyFlowPipeline.tsx | 0 .../src/components/CopyMissionActiveJobsCard.tsx | 0 .../components/CopyMissionAnalyzeJobPanel.tsx | 0 .../src/components/CopyMissionDiceButton.tsx | 0 .../src/components/CopyMissionDraftCard.tsx | 0 .../src/components/CopyMissionJobPanel.tsx | 0 .../src/components/CopyMissionResearchEditor.tsx | 0 .../components/CopyMissionResearchOverview.tsx | 0 .../components/CopyMissionScanSchedulePanel.tsx | 0 .../src/components/CopyMissionSwitcher.tsx | 0 .../src/components/CopyPersonaSwitcher.tsx | 0 .../src/components/CopySuggestedTagsPicker.tsx | 0 .../components/CreatePlacementTopicDialog.tsx | 0 .../src/components/DevToolsPanel.tsx | 0 .../src/components/EvidenceList.tsx | 0 .../src/components/ExpandGraphJobPanel.tsx | 0 .../src/components/ExpandStrategyField.tsx | 0 .../src/components/ExtensionInstallCard.tsx | 0 {web => frontend}/src/components/JobMonitor.tsx | 0 .../src/components/KnowledgeGraphPanel.tsx | 0 .../src/components/KnowledgeLayerRipple.tsx | 0 .../src/components/KnowledgeOrbitDiagram.tsx | 0 .../src/components/KnowledgeOverviewPanel.tsx | 0 {web => frontend}/src/components/Layout.tsx | 0 .../src/components/LegacyBrandRouteRedirect.tsx | 0 .../src/components/MatrixEntryRoute.tsx | 0 {web => frontend}/src/components/MemberMenu.tsx | 0 .../src/components/MobileBottomNav.tsx | 0 .../src/components/OnboardingBanner.tsx | 0 .../src/components/OnboardingGuide.tsx | 0 .../src/components/OnboardingRouteGuard.tsx | 0 .../src/components/OutreachEntryRoute.tsx | 0 .../src/components/PatrolKeywordsEditor.tsx | 0 .../src/components/PatrolNodeTagsEditor.tsx | 0 .../src/components/PersonaIslandArt.tsx | 0 .../src/components/PersonaScanSchedulePanel.tsx | 0 .../src/components/PlacementFlowNav.tsx | 0 .../src/components/PlacementFlowPipeline.tsx | 0 .../src/components/PlacementResearchSettings.tsx | 0 .../src/components/PlacementScanJobPanel.tsx | 0 .../src/components/ProductContextForm.tsx | 0 .../src/components/ProductItemForm.tsx | 0 .../src/components/ProtectedRoute.tsx | 0 .../src/components/RegisterForm.tsx | 0 .../src/components/ReplyPersonaPicker.tsx | 0 .../src/components/ResearchMapEditor.tsx | 0 .../src/components/ResearchMapOverview.tsx | 0 .../src/components/ResearchMapPanel.tsx | 0 .../src/components/ScheduleFrequencyPicker.tsx | 0 .../src/components/SourceLinkPreview.tsx | 0 .../src/components/StringListEditor.tsx | 0 .../src/components/Style8DAnalysisMeta.tsx | 0 .../src/components/Style8DDimensionEditor.tsx | 0 .../src/components/Style8DJobPanel.tsx | 0 {web => frontend}/src/components/ThemeToggle.tsx | 0 .../src/components/ThreadsAccountWorkspace.tsx | 0 .../src/components/ThreadsPostCharMeter.tsx | 0 .../src/components/TopicSwitcher.tsx | 0 .../src/components/VerifiedAuthorBadge.tsx | 0 .../src/components/ViralScanJobPanel.tsx | 0 .../src/components/islander/IslanderAvatar.tsx | 0 .../components/islander/IslanderChatPanel.tsx | 0 .../components/islander/IslanderCompanion.tsx | 0 .../src/components/islander/IslanderMarkdown.tsx | 0 {web => frontend}/src/components/ui.tsx | 0 {web => frontend}/src/hooks/useActiveBrand.ts | 0 .../src/hooks/useActiveCopyPersona.ts | 0 {web => frontend}/src/hooks/useActiveTopic.ts | 0 {web => frontend}/src/hooks/useIslanderUnlock.ts | 0 {web => frontend}/src/index.css | 0 {web => frontend}/src/lib/acAssets.ts | 0 {web => frontend}/src/lib/aiCredentials.ts | 0 {web => frontend}/src/lib/brandContext.ts | 0 {web => frontend}/src/lib/connectionMode.ts | 0 {web => frontend}/src/lib/copyFlow.ts | 0 {web => frontend}/src/lib/copyMissionJobs.ts | 0 {web => frontend}/src/lib/easterEggCatalog.ts | 0 {web => frontend}/src/lib/extensionInstall.ts | 0 {web => frontend}/src/lib/extensionSync.ts | 0 {web => frontend}/src/lib/formatDate.ts | 0 .../src/lib/islander/IslanderPageContext.tsx | 0 .../src/lib/islander/actionExecutor.ts | 0 .../src/lib/islander/buildIslanderContext.ts | 0 {web => frontend}/src/lib/islander/config.ts | 0 .../src/lib/islander/copyActions.ts | 0 .../src/lib/islander/handoffStore.ts | 0 {web => frontend}/src/lib/islander/index.ts | 0 .../src/lib/islander/islanderActions.ts | 0 .../src/lib/islander/islanderAgent.ts | 0 .../src/lib/islander/outreachActions.ts | 0 .../src/lib/islander/pageContextIntent.ts | 0 .../src/lib/islander/pageRegistry.ts | 0 .../src/lib/islander/pageSnapshot.ts | 0 .../src/lib/islander/researchActions.ts | 0 {web => frontend}/src/lib/islander/siteGuide.ts | 0 {web => frontend}/src/lib/islander/types.ts | 0 {web => frontend}/src/lib/islander/unlock.ts | 0 .../src/lib/islander/useIslanderPosition.ts | 0 {web => frontend}/src/lib/jobStatus.ts | 0 {web => frontend}/src/lib/jobTemplate.ts | 0 {web => frontend}/src/lib/knowledgeGraph.ts | 0 {web => frontend}/src/lib/memberRole.ts | 0 {web => frontend}/src/lib/onboarding.ts | 0 {web => frontend}/src/lib/pageJobScope.ts | 0 {web => frontend}/src/lib/placementFlow.ts | 0 {web => frontend}/src/lib/placementResearch.ts | 0 {web => frontend}/src/lib/placementTopics.ts | 0 {web => frontend}/src/lib/productCatalog.ts | 0 {web => frontend}/src/lib/productContext.ts | 0 {web => frontend}/src/lib/productMatch.ts | 0 {web => frontend}/src/lib/publishPersona.ts | 0 {web => frontend}/src/lib/scheduleCatalog.ts | 0 {web => frontend}/src/lib/scheduleCron.ts | 0 {web => frontend}/src/lib/searchSourceMode.ts | 0 {web => frontend}/src/lib/sourcePreview.ts | 0 {web => frontend}/src/lib/storage.ts | 0 {web => frontend}/src/lib/styleEvidence.ts | 0 {web => frontend}/src/lib/styleProfile.ts | 0 {web => frontend}/src/lib/threadsAccount.ts | 0 {web => frontend}/src/lib/threadsLinks.ts | 0 {web => frontend}/src/lib/threadsPostLimits.ts | 0 {web => frontend}/src/lib/viralSignals.ts | 0 {web => frontend}/src/main.tsx | 0 .../src/onboarding/OnboardingContext.tsx | 0 {web => frontend}/src/pages/BrandDetailPage.tsx | 0 .../src/pages/BrandProductEditPage.tsx | 0 {web => frontend}/src/pages/BrandsPage.tsx | 0 {web => frontend}/src/pages/CopyMatrixPage.tsx | 0 .../src/pages/CopyMissionDetailPage.tsx | 0 {web => frontend}/src/pages/DashboardPage.tsx | 0 {web => frontend}/src/pages/EasterEggsPage.tsx | 0 {web => frontend}/src/pages/JobDetailPage.tsx | 0 {web => frontend}/src/pages/JobSchedulesPage.tsx | 0 {web => frontend}/src/pages/JobTemplatesPage.tsx | 0 {web => frontend}/src/pages/JobsPage.tsx | 0 {web => frontend}/src/pages/LoginPage.tsx | 0 {web => frontend}/src/pages/PermissionsPage.tsx | 0 .../src/pages/PersonaDetailPage.tsx | 0 .../src/pages/PersonaMatrixPage.tsx | 0 .../src/pages/PersonaOutreachPage.tsx | 0 .../src/pages/PersonaResearchPage.tsx | 0 {web => frontend}/src/pages/PersonasPage.tsx | 0 .../src/pages/PlacementTopicResearchMapPage.tsx | 0 .../src/pages/PlacementTopicSettingsPage.tsx | 0 .../src/pages/PlacementTopicsPage.tsx | 0 {web => frontend}/src/pages/ProfilePage.tsx | 0 {web => frontend}/src/pages/SettingsPage.tsx | 0 .../src/pages/ThreadsAccountConnectionsPage.tsx | 0 .../src/pages/ThreadsAccountPublishPage.tsx | 0 {web => frontend}/src/theme/ThemeContext.tsx | 0 .../src/threads/ThreadsAccountContext.tsx | 0 {web => frontend}/src/types/api.ts | 0 {web => frontend}/src/types/brand.ts | 0 {web => frontend}/src/types/copyMission.ts | 0 {web => frontend}/src/types/placementTopic.ts | 0 {web => frontend}/src/vite-env.d.ts | 0 {web => frontend}/tsconfig.app.json | 0 {web => frontend}/tsconfig.json | 0 {web => frontend}/tsconfig.node.json | 0 {web => frontend}/vite.config.ts | 0 789 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 .DS_Store create mode 100644 backend/.DS_Store rename README.md => backend/README.md (100%) rename {cmd => backend/cmd}/tool/main.go (100%) rename {cmd => backend/cmd}/worker/main.go (100%) rename {docs => backend/docs}/job-system-plan.md (100%) rename {docs => backend/docs}/scan-placement-plan.md (100%) rename {etc => backend/etc}/gateway.dev.example.yaml (100%) rename {etc => backend/etc}/gateway.prod.yaml (100%) rename {etc => backend/etc}/gateway.worker.yaml (100%) rename {etc => backend/etc}/gateway.yaml (100%) rename gateway.go => backend/gateway.go (100%) rename {generate => backend/generate}/api/ai.api (100%) rename {generate => backend/generate}/api/auth.api (100%) rename {generate => backend/generate}/api/brand.api (100%) rename {generate => backend/generate}/api/common.api (100%) rename {generate => backend/generate}/api/copy_mission.api (100%) rename {generate => backend/generate}/api/gateway.api (100%) rename {generate => backend/generate}/api/job.api (100%) rename {generate => backend/generate}/api/member.api (100%) rename {generate => backend/generate}/api/normal.api (100%) rename {generate => backend/generate}/api/permission.api (100%) rename {generate => backend/generate}/api/persona.api (100%) rename {generate => backend/generate}/api/placement_topic.api (100%) rename {generate => backend/generate}/api/setting.api (100%) rename {generate => backend/generate}/api/threads_account.api (100%) rename {generate => backend/generate}/api/worker_internal.api (100%) rename {generate => backend/generate}/goctl/api/handler.tpl (100%) rename go.mod => backend/go.mod (100%) rename go.sum => backend/go.sum (100%) rename {internal => backend/internal}/bootstrap/admin.go (100%) rename {internal => backend/internal}/bootstrap/admin_test.go (100%) rename {internal => backend/internal}/bootstrap/init.go (100%) rename {internal => backend/internal}/config/config.go (100%) rename {internal => backend/internal}/handler/ai/chat_handler.go (100%) rename {internal => backend/internal}/handler/ai/chat_stream_handler.go (100%) rename {internal => backend/internal}/handler/ai/islander_chat_stream_handler.go (100%) rename {internal => backend/internal}/handler/ai/list_ai_provider_models_handler.go (100%) rename {internal => backend/internal}/handler/ai/list_ai_providers_handler.go (100%) rename {internal => backend/internal}/handler/auth/login_handler.go (100%) rename {internal => backend/internal}/handler/auth/logout_handler.go (100%) rename {internal => backend/internal}/handler/auth/refresh_handler.go (100%) rename {internal => backend/internal}/handler/auth/register_handler.go (100%) rename {internal => backend/internal}/handler/brand/create_brand_handler.go (100%) rename {internal => backend/internal}/handler/brand/create_brand_product_handler.go (100%) rename {internal => backend/internal}/handler/brand/delete_brand_handler.go (100%) rename {internal => backend/internal}/handler/brand/delete_brand_product_handler.go (100%) rename {internal => backend/internal}/handler/brand/expand_knowledge_graph_handler.go (100%) rename {internal => backend/internal}/handler/brand/generate_brand_content_matrix_handler.go (100%) rename {internal => backend/internal}/handler/brand/generate_outreach_drafts_handler.go (100%) rename {internal => backend/internal}/handler/brand/get_brand_content_matrix_handler.go (100%) rename {internal => backend/internal}/handler/brand/get_brand_handler.go (100%) rename {internal => backend/internal}/handler/brand/get_brand_scan_schedule_handler.go (100%) rename {internal => backend/internal}/handler/brand/get_knowledge_graph_handler.go (100%) rename {internal => backend/internal}/handler/brand/list_brand_products_handler.go (100%) rename {internal => backend/internal}/handler/brand/list_brand_scan_posts_handler.go (100%) rename {internal => backend/internal}/handler/brand/list_brands_handler.go (100%) rename {internal => backend/internal}/handler/brand/patch_knowledge_graph_nodes_handler.go (100%) rename {internal => backend/internal}/handler/brand/patch_scan_post_outreach_handler.go (100%) rename {internal => backend/internal}/handler/brand/publish_outreach_draft_handler.go (100%) rename {internal => backend/internal}/handler/brand/start_brand_scan_job_handler.go (100%) rename {internal => backend/internal}/handler/brand/update_brand_handler.go (100%) rename {internal => backend/internal}/handler/brand/update_brand_product_handler.go (100%) rename {internal => backend/internal}/handler/brand/upsert_brand_scan_schedule_handler.go (100%) rename {internal => backend/internal}/handler/copy_mission/create_copy_mission_handler.go (100%) rename {internal => backend/internal}/handler/copy_mission/delete_copy_mission_handler.go (100%) rename {internal => backend/internal}/handler/copy_mission/generate_copy_mission_matrix_handler.go (100%) rename {internal => backend/internal}/handler/copy_mission/get_copy_mission_handler.go (100%) rename {internal => backend/internal}/handler/copy_mission/get_copy_mission_scan_schedule_handler.go (100%) rename {internal => backend/internal}/handler/copy_mission/inspire_copy_mission_handler.go (100%) rename {internal => backend/internal}/handler/copy_mission/list_copy_mission_copy_drafts_handler.go (100%) rename {internal => backend/internal}/handler/copy_mission/list_copy_mission_scan_posts_handler.go (100%) rename {internal => backend/internal}/handler/copy_mission/list_copy_missions_handler.go (100%) rename {internal => backend/internal}/handler/copy_mission/start_copy_mission_analyze_job_handler.go (100%) rename {internal => backend/internal}/handler/copy_mission/start_copy_mission_copy_draft_job_handler.go (100%) rename {internal => backend/internal}/handler/copy_mission/start_copy_mission_matrix_job_handler.go (100%) rename {internal => backend/internal}/handler/copy_mission/start_copy_mission_scan_job_handler.go (100%) rename {internal => backend/internal}/handler/copy_mission/update_copy_mission_handler.go (100%) rename {internal => backend/internal}/handler/copy_mission/upsert_copy_mission_scan_schedule_handler.go (100%) rename {internal => backend/internal}/handler/job/ack_worker_job_cancel_handler.go (100%) rename {internal => backend/internal}/handler/job/analyze_style8_d_from_worker_handler.go (100%) rename {internal => backend/internal}/handler/job/cancel_job_handler.go (100%) rename {internal => backend/internal}/handler/job/check_worker_job_cancel_handler.go (100%) rename {internal => backend/internal}/handler/job/claim_worker_job_handler.go (100%) rename {internal => backend/internal}/handler/job/complete_worker_job_handler.go (100%) rename {internal => backend/internal}/handler/job/create_job_handler.go (100%) rename {internal => backend/internal}/handler/job/create_job_schedule_handler.go (100%) rename {internal => backend/internal}/handler/job/delete_job_schedule_handler.go (100%) rename {internal => backend/internal}/handler/job/disable_job_schedule_handler.go (100%) rename {internal => backend/internal}/handler/job/enable_job_schedule_handler.go (100%) rename {internal => backend/internal}/handler/job/fail_worker_job_handler.go (100%) rename {internal => backend/internal}/handler/job/get_job_handler.go (100%) rename {internal => backend/internal}/handler/job/get_job_template_handler.go (100%) rename {internal => backend/internal}/handler/job/get_worker_threads_account_session_handler.go (100%) rename {internal => backend/internal}/handler/job/list_job_events_handler.go (100%) rename {internal => backend/internal}/handler/job/list_job_schedules_handler.go (100%) rename {internal => backend/internal}/handler/job/list_job_templates_handler.go (100%) rename {internal => backend/internal}/handler/job/list_jobs_handler.go (100%) rename {internal => backend/internal}/handler/job/refresh_worker_job_lock_handler.go (100%) rename {internal => backend/internal}/handler/job/retry_job_handler.go (100%) rename {internal => backend/internal}/handler/job/store_persona_style_profile_from_worker_handler.go (100%) rename {internal => backend/internal}/handler/job/update_job_schedule_handler.go (100%) rename {internal => backend/internal}/handler/job/update_worker_job_progress_handler.go (100%) rename {internal => backend/internal}/handler/job/upsert_job_template_handler.go (100%) rename {internal => backend/internal}/handler/member/get_member_me_handler.go (100%) rename {internal => backend/internal}/handler/member/get_member_placement_settings_handler.go (100%) rename {internal => backend/internal}/handler/member/update_member_me_handler.go (100%) rename {internal => backend/internal}/handler/member/update_member_placement_settings_handler.go (100%) rename {internal => backend/internal}/handler/normal/health_handler.go (100%) rename {internal => backend/internal}/handler/permission/get_me_permissions_handler.go (100%) rename {internal => backend/internal}/handler/permission/get_permission_catalog_handler.go (100%) rename {internal => backend/internal}/handler/persona/create_persona_handler.go (100%) rename {internal => backend/internal}/handler/persona/delete_persona_handler.go (100%) rename {internal => backend/internal}/handler/persona/generate_persona_copy_draft_handler.go (100%) rename {internal => backend/internal}/handler/persona/get_persona_handler.go (100%) rename {internal => backend/internal}/handler/persona/list_persona_copy_drafts_handler.go (100%) rename {internal => backend/internal}/handler/persona/list_persona_viral_scan_posts_handler.go (100%) rename {internal => backend/internal}/handler/persona/list_personas_handler.go (100%) rename {internal => backend/internal}/handler/persona/publish_persona_copy_draft_handler.go (100%) rename {internal => backend/internal}/handler/persona/start_persona_style_analysis_handler.go (100%) rename {internal => backend/internal}/handler/persona/start_persona_viral_scan_job_handler.go (100%) rename {internal => backend/internal}/handler/persona/update_persona_copy_draft_handler.go (100%) rename {internal => backend/internal}/handler/persona/update_persona_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/batch_delete_placement_topic_scan_posts_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/create_placement_topic_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/delete_placement_topic_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/delete_placement_topic_scan_post_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/expand_placement_topic_graph_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/generate_placement_topic_content_matrix_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/generate_placement_topic_outreach_drafts_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/get_placement_topic_content_matrix_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/get_placement_topic_graph_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/get_placement_topic_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/get_placement_topic_scan_schedule_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/list_placement_topic_scan_posts_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/list_placement_topics_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/patch_placement_topic_graph_nodes_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/patch_placement_topic_scan_post_outreach_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/publish_placement_topic_outreach_draft_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/start_placement_topic_scan_job_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/update_placement_topic_handler.go (100%) rename {internal => backend/internal}/handler/placement_topic/upsert_placement_topic_scan_schedule_handler.go (100%) rename {internal => backend/internal}/handler/routes.go (100%) rename {internal => backend/internal}/handler/setting/delete_setting_handler.go (100%) rename {internal => backend/internal}/handler/setting/get_setting_handler.go (100%) rename {internal => backend/internal}/handler/setting/list_settings_handler.go (100%) rename {internal => backend/internal}/handler/setting/upsert_setting_handler.go (100%) rename {internal => backend/internal}/handler/threads_account/activate_threads_account_handler.go (100%) rename {internal => backend/internal}/handler/threads_account/create_threads_account_handler.go (100%) rename {internal => backend/internal}/handler/threads_account/get_threads_account_ai_settings_handler.go (100%) rename {internal => backend/internal}/handler/threads_account/get_threads_account_connection_handler.go (100%) rename {internal => backend/internal}/handler/threads_account/get_threads_account_handler.go (100%) rename {internal => backend/internal}/handler/threads_account/import_threads_account_session_handler.go (100%) rename {internal => backend/internal}/handler/threads_account/list_threads_accounts_handler.go (100%) rename {internal => backend/internal}/handler/threads_account/update_threads_account_ai_settings_handler.go (100%) rename {internal => backend/internal}/handler/threads_account/update_threads_account_connection_handler.go (100%) rename {internal => backend/internal}/handler/threads_account/update_threads_account_handler.go (100%) rename {internal => backend/internal}/library/authctx/context.go (100%) rename {internal => backend/internal}/library/brave/breaker.go (100%) rename {internal => backend/internal}/library/brave/client.go (100%) rename {internal => backend/internal}/library/clock/clock.go (100%) rename {internal => backend/internal}/library/clock/clock_test.go (100%) rename {internal => backend/internal}/library/errors/code/types.go (100%) rename {internal => backend/internal}/library/errors/errors.go (100%) rename {internal => backend/internal}/library/exa/client.go (100%) rename {internal => backend/internal}/library/exa/client_test.go (100%) rename {internal => backend/internal}/library/knowledge/bootstrap.go (100%) rename {internal => backend/internal}/library/knowledge/bootstrap_test.go (100%) rename {internal => backend/internal}/library/knowledge/brave_collect.go (100%) rename {internal => backend/internal}/library/knowledge/brave_collect_test.go (100%) rename {internal => backend/internal}/library/knowledge/expand_strategy.go (100%) rename {internal => backend/internal}/library/knowledge/expand_strategy_test.go (100%) rename {internal => backend/internal}/library/knowledge/graph.go (100%) rename {internal => backend/internal}/library/knowledge/patrol_input.go (100%) rename {internal => backend/internal}/library/knowledge/patrol_phrase.go (100%) rename {internal => backend/internal}/library/knowledge/patrol_phrase_test.go (100%) rename {internal => backend/internal}/library/knowledge/patrol_rank.go (100%) rename {internal => backend/internal}/library/knowledge/patrol_rank_test.go (100%) rename {internal => backend/internal}/library/knowledge/patrol_resolve.go (100%) rename {internal => backend/internal}/library/knowledge/patrol_resolve_test.go (100%) rename {internal => backend/internal}/library/knowledge/patrol_tags.go (100%) rename {internal => backend/internal}/library/knowledge/patrol_tags_test.go (100%) rename {internal => backend/internal}/library/knowledge/quality.go (100%) rename {internal => backend/internal}/library/knowledge/quality_test.go (100%) rename {internal => backend/internal}/library/knowledge/queries.go (100%) rename {internal => backend/internal}/library/knowledge/queries_test.go (100%) rename {internal => backend/internal}/library/knowledge/synth.go (100%) rename {internal => backend/internal}/library/knowledge/synth_test.go (100%) rename {internal => backend/internal}/library/matrix/copy_generate.go (100%) rename {internal => backend/internal}/library/matrix/generate.go (100%) rename {internal => backend/internal}/library/matrix/samples.go (100%) rename {internal => backend/internal}/library/mongo/brand_scope.go (100%) rename {internal => backend/internal}/library/mongo/client.go (100%) rename {internal => backend/internal}/library/mongo/ensure_index.go (100%) rename {internal => backend/internal}/library/outreach/generate.go (100%) rename {internal => backend/internal}/library/outreach/generate_test.go (100%) rename {internal => backend/internal}/library/permmatch/match.go (100%) rename {internal => backend/internal}/library/permmatch/match_test.go (100%) rename {internal => backend/internal}/library/placement/ai_generate.go (100%) rename {internal => backend/internal}/library/placement/context.go (100%) rename {internal => backend/internal}/library/placement/context_prompt.go (100%) rename {internal => backend/internal}/library/placement/context_prompt_test.go (100%) rename {internal => backend/internal}/library/placement/crawler_exec.go (100%) rename {internal => backend/internal}/library/placement/crawler_exec_test.go (100%) rename {internal => backend/internal}/library/placement/crawler_polite.go (100%) rename {internal => backend/internal}/library/placement/discover.go (100%) rename {internal => backend/internal}/library/placement/discover_route.go (100%) rename {internal => backend/internal}/library/placement/discover_route_test.go (100%) rename {internal => backend/internal}/library/placement/dual_track.go (100%) rename {internal => backend/internal}/library/placement/effective_strategy.go (100%) rename {internal => backend/internal}/library/placement/effective_strategy_test.go (100%) rename {internal => backend/internal}/library/placement/exclusion.go (100%) rename {internal => backend/internal}/library/placement/filter.go (100%) rename {internal => backend/internal}/library/placement/filter_test.go (100%) rename {internal => backend/internal}/library/placement/parse_threads.go (100%) rename {internal => backend/internal}/library/placement/patrol_queries.go (100%) rename {internal => backend/internal}/library/placement/patrol_queries_test.go (100%) rename {internal => backend/internal}/library/placement/product_catalog.go (100%) rename {internal => backend/internal}/library/placement/product_context.go (100%) rename {internal => backend/internal}/library/placement/product_match.go (100%) rename {internal => backend/internal}/library/placement/query_build.go (100%) rename {internal => backend/internal}/library/placement/recency.go (100%) rename {internal => backend/internal}/library/placement/research_map.go (100%) rename {internal => backend/internal}/library/placement/research_map_test.go (100%) rename {internal => backend/internal}/library/placement/scrape_replies.go (100%) rename {internal => backend/internal}/library/placement/scrape_replies_test.go (100%) rename {internal => backend/internal}/library/placement/source_mode.go (100%) rename {internal => backend/internal}/library/placement/source_mode_test.go (100%) rename {internal => backend/internal}/library/placement/threads_api.go (100%) rename {internal => backend/internal}/library/prompt/compose.go (100%) rename {internal => backend/internal}/library/prompt/compose_test.go (100%) rename {internal => backend/internal}/library/prompt/errors.go (100%) rename {internal => backend/internal}/library/prompt/files/ai.chat.system.md (100%) rename {internal => backend/internal}/library/prompt/files/ai.islander.system.md (100%) rename {internal => backend/internal}/library/prompt/files/knowledge_graph.queries.json (100%) rename {internal => backend/internal}/library/prompt/files/knowledge_graph.supplemental.md (100%) rename {internal => backend/internal}/library/prompt/files/knowledge_graph.system.md (100%) rename {internal => backend/internal}/library/prompt/files/knowledge_graph.user.md (100%) rename {internal => backend/internal}/library/prompt/files/knowledge_graph_llm.system.md (100%) rename {internal => backend/internal}/library/prompt/files/knowledge_graph_llm.user.md (100%) rename {internal => backend/internal}/library/prompt/files/matrix_copy.system.md (100%) rename {internal => backend/internal}/library/prompt/files/matrix_copy.user.md (100%) rename {internal => backend/internal}/library/prompt/files/matrix_placement.system.md (100%) rename {internal => backend/internal}/library/prompt/files/matrix_placement.user.md (100%) rename {internal => backend/internal}/library/prompt/files/outreach_placement.system.md (100%) rename {internal => backend/internal}/library/prompt/files/outreach_placement.user.md (100%) rename {internal => backend/internal}/library/prompt/files/overlay.md (100%) rename {internal => backend/internal}/library/prompt/files/style8d.schema.md (100%) rename {internal => backend/internal}/library/prompt/files/style8d.system.md (100%) rename {internal => backend/internal}/library/prompt/registry.go (100%) rename {internal => backend/internal}/library/redact/redact.go (100%) rename {internal => backend/internal}/library/redact/redact_test.go (100%) rename {internal => backend/internal}/library/redis/client.go (100%) rename {internal => backend/internal}/library/style8d/analyze.go (100%) rename {internal => backend/internal}/library/style8d/analyze_test.go (100%) rename {internal => backend/internal}/library/style8d/prompt.go (100%) rename {internal => backend/internal}/library/style8d/prompt_test.go (100%) rename {internal => backend/internal}/library/threadsapi/client.go (100%) rename {internal => backend/internal}/library/threadsapi/publish.go (100%) rename {internal => backend/internal}/library/threadsapi/replies.go (100%) rename {internal => backend/internal}/library/threadspost/limits.go (100%) rename {internal => backend/internal}/library/threadspost/limits_test.go (100%) rename {internal => backend/internal}/library/validate/validate.go (100%) rename {internal => backend/internal}/library/viral/analyze_viral.go (100%) rename {internal => backend/internal}/library/viral/discover.go (100%) rename {internal => backend/internal}/library/viral/discover_accounts.go (100%) rename {internal => backend/internal}/library/viral/discover_accounts_test.go (100%) rename {internal => backend/internal}/library/viral/discover_budget_test.go (100%) rename {internal => backend/internal}/library/viral/discover_graceful_test.go (100%) rename {internal => backend/internal}/library/viral/enrich_accounts.go (100%) rename {internal => backend/internal}/library/viral/flexible_json.go (100%) rename {internal => backend/internal}/library/viral/mission_inspire.go (100%) rename {internal => backend/internal}/library/viral/mission_inspire_collect.go (100%) rename {internal => backend/internal}/library/viral/mission_inspire_test.go (100%) rename {internal => backend/internal}/library/viral/mission_research_map.go (100%) rename {internal => backend/internal}/library/viral/mission_research_map_test.go (100%) rename {internal => backend/internal}/library/viral/quality.go (100%) rename {internal => backend/internal}/library/viral/quality_test.go (100%) rename {internal => backend/internal}/library/viral/reference_accounts.go (100%) rename {internal => backend/internal}/library/viral/reference_accounts_fallback_test.go (100%) rename {internal => backend/internal}/library/viral/reference_accounts_test.go (100%) rename {internal => backend/internal}/library/viral/replicate.go (100%) rename {internal => backend/internal}/library/viral/research_map.go (100%) rename {internal => backend/internal}/library/viral/score.go (100%) rename {internal => backend/internal}/library/viral/search_tags.go (100%) rename {internal => backend/internal}/library/viral/search_tags_test.go (100%) rename {internal => backend/internal}/library/viral/tags.go (100%) rename {internal => backend/internal}/library/viral/tags_test.go (100%) rename {internal => backend/internal}/library/websearch/client.go (100%) rename {internal => backend/internal}/logic/ai/actor.go (100%) rename {internal => backend/internal}/logic/ai/chat_logic.go (100%) rename {internal => backend/internal}/logic/ai/chat_stream_logic.go (100%) rename {internal => backend/internal}/logic/ai/credential.go (100%) rename {internal => backend/internal}/logic/ai/islander_chat_stream_logic.go (100%) rename {internal => backend/internal}/logic/ai/list_ai_provider_models_logic.go (100%) rename {internal => backend/internal}/logic/ai/list_ai_providers_logic.go (100%) rename {internal => backend/internal}/logic/ai/mapper.go (100%) rename {internal => backend/internal}/logic/auth/login_logic.go (100%) rename {internal => backend/internal}/logic/auth/logout_logic.go (100%) rename {internal => backend/internal}/logic/auth/mapper.go (100%) rename {internal => backend/internal}/logic/auth/refresh_logic.go (100%) rename {internal => backend/internal}/logic/auth/register_logic.go (100%) rename {internal => backend/internal}/logic/authz/admin.go (100%) rename {internal => backend/internal}/logic/brand/actor.go (100%) rename {internal => backend/internal}/logic/brand/create_brand_logic.go (100%) rename {internal => backend/internal}/logic/brand/create_brand_product_logic.go (100%) rename {internal => backend/internal}/logic/brand/delete_brand_logic.go (100%) rename {internal => backend/internal}/logic/brand/delete_brand_product_logic.go (100%) rename {internal => backend/internal}/logic/brand/expand_knowledge_graph_logic.go (100%) rename {internal => backend/internal}/logic/brand/generate_brand_content_matrix_logic.go (100%) rename {internal => backend/internal}/logic/brand/generate_outreach_drafts_logic.go (100%) rename {internal => backend/internal}/logic/brand/get_brand_content_matrix_logic.go (100%) rename {internal => backend/internal}/logic/brand/get_brand_logic.go (100%) rename {internal => backend/internal}/logic/brand/get_brand_scan_schedule_logic.go (100%) rename {internal => backend/internal}/logic/brand/get_knowledge_graph_logic.go (100%) rename {internal => backend/internal}/logic/brand/list_brand_products_logic.go (100%) rename {internal => backend/internal}/logic/brand/list_brand_scan_posts_logic.go (100%) rename {internal => backend/internal}/logic/brand/list_brands_logic.go (100%) rename {internal => backend/internal}/logic/brand/mapper.go (100%) rename {internal => backend/internal}/logic/brand/patch_knowledge_graph_nodes_logic.go (100%) rename {internal => backend/internal}/logic/brand/patch_scan_post_outreach_logic.go (100%) rename {internal => backend/internal}/logic/brand/publish_outreach_draft_logic.go (100%) rename {internal => backend/internal}/logic/brand/start_brand_scan_job_logic.go (100%) rename {internal => backend/internal}/logic/brand/update_brand_logic.go (100%) rename {internal => backend/internal}/logic/brand/update_brand_product_logic.go (100%) rename {internal => backend/internal}/logic/brand/upsert_brand_scan_schedule_logic.go (100%) rename {internal => backend/internal}/logic/copy_mission/actor.go (100%) rename {internal => backend/internal}/logic/copy_mission/create_copy_mission_logic.go (100%) rename {internal => backend/internal}/logic/copy_mission/delete_copy_mission_logic.go (100%) rename {internal => backend/internal}/logic/copy_mission/generate_copy_mission_matrix_logic.go (100%) rename {internal => backend/internal}/logic/copy_mission/get_copy_mission_logic.go (100%) rename {internal => backend/internal}/logic/copy_mission/get_copy_mission_scan_schedule_logic.go (100%) rename {internal => backend/internal}/logic/copy_mission/inspire_copy_mission_logic.go (100%) rename {internal => backend/internal}/logic/copy_mission/list_copy_mission_copy_drafts_logic.go (100%) rename {internal => backend/internal}/logic/copy_mission/list_copy_mission_scan_posts_logic.go (100%) rename {internal => backend/internal}/logic/copy_mission/list_copy_missions_logic.go (100%) rename {internal => backend/internal}/logic/copy_mission/mapper.go (100%) rename {internal => backend/internal}/logic/copy_mission/schedule_helper.go (100%) rename {internal => backend/internal}/logic/copy_mission/start_copy_mission_analyze_job_logic.go (100%) rename {internal => backend/internal}/logic/copy_mission/start_copy_mission_copy_draft_job_logic.go (100%) rename {internal => backend/internal}/logic/copy_mission/start_copy_mission_matrix_job_logic.go (100%) rename {internal => backend/internal}/logic/copy_mission/start_copy_mission_scan_job_logic.go (100%) rename {internal => backend/internal}/logic/copy_mission/update_copy_mission_logic.go (100%) rename {internal => backend/internal}/logic/copy_mission/upsert_copy_mission_scan_schedule_logic.go (100%) rename {internal => backend/internal}/logic/job/ack_worker_job_cancel_logic.go (100%) rename {internal => backend/internal}/logic/job/actor.go (100%) rename {internal => backend/internal}/logic/job/analyze_style8_d_from_worker_logic.go (100%) rename {internal => backend/internal}/logic/job/cancel_job_logic.go (100%) rename {internal => backend/internal}/logic/job/check_worker_job_cancel_logic.go (100%) rename {internal => backend/internal}/logic/job/claim_worker_job_logic.go (100%) rename {internal => backend/internal}/logic/job/complete_worker_job_logic.go (100%) rename {internal => backend/internal}/logic/job/create_job_logic.go (100%) rename {internal => backend/internal}/logic/job/create_job_schedule_logic.go (100%) rename {internal => backend/internal}/logic/job/delete_job_schedule_logic.go (100%) rename {internal => backend/internal}/logic/job/disable_job_schedule_logic.go (100%) rename {internal => backend/internal}/logic/job/enable_job_schedule_logic.go (100%) rename {internal => backend/internal}/logic/job/fail_worker_job_logic.go (100%) rename {internal => backend/internal}/logic/job/get_job_logic.go (100%) rename {internal => backend/internal}/logic/job/get_job_template_logic.go (100%) rename {internal => backend/internal}/logic/job/get_worker_threads_account_session_logic.go (100%) rename {internal => backend/internal}/logic/job/list_job_events_logic.go (100%) rename {internal => backend/internal}/logic/job/list_job_schedules_logic.go (100%) rename {internal => backend/internal}/logic/job/list_job_templates_logic.go (100%) rename {internal => backend/internal}/logic/job/list_jobs_logic.go (100%) rename {internal => backend/internal}/logic/job/mapper.go (100%) rename {internal => backend/internal}/logic/job/ownership.go (100%) rename {internal => backend/internal}/logic/job/ownership_test.go (100%) rename {internal => backend/internal}/logic/job/refresh_worker_job_lock_logic.go (100%) rename {internal => backend/internal}/logic/job/retry_job_logic.go (100%) rename {internal => backend/internal}/logic/job/store_persona_style_profile_from_worker_logic.go (100%) rename {internal => backend/internal}/logic/job/update_job_schedule_logic.go (100%) rename {internal => backend/internal}/logic/job/update_worker_job_progress_logic.go (100%) rename {internal => backend/internal}/logic/job/upsert_job_template_logic.go (100%) rename {internal => backend/internal}/logic/job/worker_helpers.go (100%) rename {internal => backend/internal}/logic/member/get_member_me_logic.go (100%) rename {internal => backend/internal}/logic/member/get_member_placement_settings_logic.go (100%) rename {internal => backend/internal}/logic/member/mapper.go (100%) rename {internal => backend/internal}/logic/member/update_member_me_logic.go (100%) rename {internal => backend/internal}/logic/member/update_member_placement_settings_logic.go (100%) rename {internal => backend/internal}/logic/normal/health_logic.go (100%) rename {internal => backend/internal}/logic/permission/get_me_permissions_logic.go (100%) rename {internal => backend/internal}/logic/permission/get_permission_catalog_logic.go (100%) rename {internal => backend/internal}/logic/permission/mapper.go (100%) rename {internal => backend/internal}/logic/persona/actor.go (100%) rename {internal => backend/internal}/logic/persona/create_persona_logic.go (100%) rename {internal => backend/internal}/logic/persona/delete_persona_logic.go (100%) rename {internal => backend/internal}/logic/persona/generate_persona_copy_draft_logic.go (100%) rename {internal => backend/internal}/logic/persona/get_persona_logic.go (100%) rename {internal => backend/internal}/logic/persona/list_persona_copy_drafts_logic.go (100%) rename {internal => backend/internal}/logic/persona/list_persona_viral_scan_posts_logic.go (100%) rename {internal => backend/internal}/logic/persona/list_personas_logic.go (100%) rename {internal => backend/internal}/logic/persona/mapper.go (100%) rename {internal => backend/internal}/logic/persona/publish_persona_copy_draft_logic.go (100%) rename {internal => backend/internal}/logic/persona/start_persona_style_analysis_logic.go (100%) rename {internal => backend/internal}/logic/persona/start_persona_viral_scan_job_logic.go (100%) rename {internal => backend/internal}/logic/persona/update_persona_copy_draft_logic.go (100%) rename {internal => backend/internal}/logic/persona/update_persona_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/actor.go (100%) rename {internal => backend/internal}/logic/placement_topic/batch_delete_placement_topic_scan_posts_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/create_placement_topic_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/delete_placement_topic_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/delete_placement_topic_scan_post_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/expand_placement_topic_graph_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/generate_placement_topic_content_matrix_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/get_placement_topic_content_matrix_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/get_placement_topic_graph_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/get_placement_topic_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/get_placement_topic_scan_schedule_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/list_placement_topic_scan_posts_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/list_placement_topics_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/mapper.go (100%) rename {internal => backend/internal}/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/patch_placement_topic_scan_post_outreach_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/publish_placement_topic_outreach_draft_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/scope.go (100%) rename {internal => backend/internal}/logic/placement_topic/start_placement_topic_scan_job_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/update_placement_topic_logic.go (100%) rename {internal => backend/internal}/logic/placement_topic/upsert_placement_topic_scan_schedule_logic.go (100%) rename {internal => backend/internal}/logic/setting/delete_setting_logic.go (100%) rename {internal => backend/internal}/logic/setting/get_setting_logic.go (100%) rename {internal => backend/internal}/logic/setting/list_settings_logic.go (100%) rename {internal => backend/internal}/logic/setting/mapper.go (100%) rename {internal => backend/internal}/logic/setting/upsert_setting_logic.go (100%) rename {internal => backend/internal}/logic/threads_account/activate_threads_account_logic.go (100%) rename {internal => backend/internal}/logic/threads_account/actor.go (100%) rename {internal => backend/internal}/logic/threads_account/create_threads_account_logic.go (100%) rename {internal => backend/internal}/logic/threads_account/get_threads_account_ai_settings_logic.go (100%) rename {internal => backend/internal}/logic/threads_account/get_threads_account_connection_logic.go (100%) rename {internal => backend/internal}/logic/threads_account/get_threads_account_logic.go (100%) rename {internal => backend/internal}/logic/threads_account/import_threads_account_session_logic.go (100%) rename {internal => backend/internal}/logic/threads_account/list_threads_accounts_logic.go (100%) rename {internal => backend/internal}/logic/threads_account/mapper.go (100%) rename {internal => backend/internal}/logic/threads_account/update_threads_account_ai_settings_logic.go (100%) rename {internal => backend/internal}/logic/threads_account/update_threads_account_connection_logic.go (100%) rename {internal => backend/internal}/logic/threads_account/update_threads_account_logic.go (100%) rename {internal => backend/internal}/middleware/auth.go (100%) rename {internal => backend/internal}/middleware/auth_middleware.go (100%) rename {internal => backend/internal}/middleware/auth_test.go (100%) rename {internal => backend/internal}/middleware/authjwt_middleware.go (100%) rename {internal => backend/internal}/middleware/memberauth_middleware.go (100%) rename {internal => backend/internal}/middleware/permissionrbac_middleware.go (100%) rename {internal => backend/internal}/middleware/workersecret_middleware.go (100%) rename {internal => backend/internal}/model/ai/domain/enum/provider.go (100%) rename {internal => backend/internal}/model/ai/domain/usecase/ai.go (100%) rename {internal => backend/internal}/model/ai/provider/openai_compatible.go (100%) rename {internal => backend/internal}/model/ai/provider/openai_compatible_test.go (100%) rename {internal => backend/internal}/model/ai/usecase/provider_map.go (100%) rename {internal => backend/internal}/model/ai/usecase/usecase.go (100%) rename {internal => backend/internal}/model/auth/domain/repository/token_revoke.go (100%) rename {internal => backend/internal}/model/auth/domain/usecase/token.go (100%) rename {internal => backend/internal}/model/auth/repository/token_revoke_redis.go (100%) rename {internal => backend/internal}/model/auth/usecase/token.go (100%) rename {internal => backend/internal}/model/brand/domain/entity/brand.go (100%) rename {internal => backend/internal}/model/brand/domain/entity/product.go (100%) rename {internal => backend/internal}/model/brand/domain/entity/research_map.go (100%) rename {internal => backend/internal}/model/brand/domain/repository/repository.go (100%) rename {internal => backend/internal}/model/brand/domain/usecase/usecase.go (100%) rename {internal => backend/internal}/model/brand/repository/mongo.go (100%) rename {internal => backend/internal}/model/brand/usecase/products.go (100%) rename {internal => backend/internal}/model/brand/usecase/usecase.go (100%) rename {internal => backend/internal}/model/content_matrix/domain/entity/matrix.go (100%) rename {internal => backend/internal}/model/content_matrix/domain/repository/repository.go (100%) rename {internal => backend/internal}/model/content_matrix/domain/usecase/usecase.go (100%) rename {internal => backend/internal}/model/content_matrix/repository/mongo.go (100%) rename {internal => backend/internal}/model/content_matrix/usecase/usecase.go (100%) rename {internal => backend/internal}/model/copy_draft/domain/entity/draft.go (100%) rename {internal => backend/internal}/model/copy_draft/domain/repository/repository.go (100%) rename {internal => backend/internal}/model/copy_draft/domain/usecase/usecase.go (100%) rename {internal => backend/internal}/model/copy_draft/repository/mongo.go (100%) rename {internal => backend/internal}/model/copy_draft/usecase/usecase.go (100%) rename {internal => backend/internal}/model/copy_mission/domain/entity/mission.go (100%) rename {internal => backend/internal}/model/copy_mission/domain/repository/repository.go (100%) rename {internal => backend/internal}/model/copy_mission/domain/usecase/usecase.go (100%) rename {internal => backend/internal}/model/copy_mission/repository/mongo.go (100%) rename {internal => backend/internal}/model/copy_mission/usecase/usecase.go (100%) rename {internal => backend/internal}/model/job/cron/next_run.go (100%) rename {internal => backend/internal}/model/job/cron/next_run_test.go (100%) rename {internal => backend/internal}/model/job/domain/entity/event.go (100%) rename {internal => backend/internal}/model/job/domain/entity/run.go (100%) rename {internal => backend/internal}/model/job/domain/entity/schedule.go (100%) rename {internal => backend/internal}/model/job/domain/entity/template.go (100%) rename {internal => backend/internal}/model/job/domain/enum/status.go (100%) rename {internal => backend/internal}/model/job/domain/repository/event.go (100%) rename {internal => backend/internal}/model/job/domain/repository/queue.go (100%) rename {internal => backend/internal}/model/job/domain/repository/run.go (100%) rename {internal => backend/internal}/model/job/domain/repository/schedule.go (100%) rename {internal => backend/internal}/model/job/domain/repository/template.go (100%) rename {internal => backend/internal}/model/job/domain/usecase/job.go (100%) rename {internal => backend/internal}/model/job/repository/mongo_event.go (100%) rename {internal => backend/internal}/model/job/repository/mongo_run.go (100%) rename {internal => backend/internal}/model/job/repository/mongo_schedule.go (100%) rename {internal => backend/internal}/model/job/repository/mongo_template.go (100%) rename {internal => backend/internal}/model/job/repository/redis_queue.go (100%) rename {internal => backend/internal}/model/job/repository/redis_queue_test.go (100%) rename {internal => backend/internal}/model/job/resume/resume.go (100%) rename {internal => backend/internal}/model/job/resume/resume_test.go (100%) rename {internal => backend/internal}/model/job/usecase/cancel_test.go (100%) rename {internal => backend/internal}/model/job/usecase/dedupe_test.go (100%) rename {internal => backend/internal}/model/job/usecase/helpers.go (100%) rename {internal => backend/internal}/model/job/usecase/maintenance.go (100%) rename {internal => backend/internal}/model/job/usecase/maintenance_test.go (100%) rename {internal => backend/internal}/model/job/usecase/refresh_lock_test.go (100%) rename {internal => backend/internal}/model/job/usecase/retry_resume_test.go (100%) rename {internal => backend/internal}/model/job/usecase/retry_test.go (100%) rename {internal => backend/internal}/model/job/usecase/schedule.go (100%) rename {internal => backend/internal}/model/job/usecase/schedule_payload.go (100%) rename {internal => backend/internal}/model/job/usecase/schedule_payload_test.go (100%) rename {internal => backend/internal}/model/job/usecase/schedule_test.go (100%) rename {internal => backend/internal}/model/job/usecase/step_resume.go (100%) rename {internal => backend/internal}/model/job/usecase/template.go (100%) rename {internal => backend/internal}/model/job/usecase/template_test.go (100%) rename {internal => backend/internal}/model/job/usecase/test_mocks.go (100%) rename {internal => backend/internal}/model/job/usecase/usecase.go (100%) rename {internal => backend/internal}/model/knowledge_graph/domain/entity/graph.go (100%) rename {internal => backend/internal}/model/knowledge_graph/domain/repository/repository.go (100%) rename {internal => backend/internal}/model/knowledge_graph/domain/usecase/usecase.go (100%) rename {internal => backend/internal}/model/knowledge_graph/repository/mongo.go (100%) rename {internal => backend/internal}/model/knowledge_graph/usecase/usecase.go (100%) rename {internal => backend/internal}/model/member/domain/entity/member.go (100%) rename {internal => backend/internal}/model/member/domain/repository/member.go (100%) rename {internal => backend/internal}/model/member/domain/usecase/member.go (100%) rename {internal => backend/internal}/model/member/repository/mongo.go (100%) rename {internal => backend/internal}/model/member/usecase/usecase.go (100%) rename {internal => backend/internal}/model/outreach_draft/domain/entity/draft.go (100%) rename {internal => backend/internal}/model/outreach_draft/domain/repository/repository.go (100%) rename {internal => backend/internal}/model/outreach_draft/domain/usecase/usecase.go (100%) rename {internal => backend/internal}/model/outreach_draft/repository/mongo.go (100%) rename {internal => backend/internal}/model/outreach_draft/usecase/errors.go (100%) rename {internal => backend/internal}/model/outreach_draft/usecase/usecase.go (100%) rename {internal => backend/internal}/model/permission/domain/entity/permission.go (100%) rename {internal => backend/internal}/model/permission/domain/repository/permission.go (100%) rename {internal => backend/internal}/model/permission/domain/usecase/permission.go (100%) rename {internal => backend/internal}/model/permission/repository/mongo_permission.go (100%) rename {internal => backend/internal}/model/permission/repository/mongo_role_permission.go (100%) rename {internal => backend/internal}/model/permission/repository/object_id.go (100%) rename {internal => backend/internal}/model/permission/usecase/usecase.go (100%) rename {internal => backend/internal}/model/permission/usecase/usecase_test.go (100%) rename {internal => backend/internal}/model/persona/domain/entity/copy_research_map.go (100%) rename {internal => backend/internal}/model/persona/domain/entity/persona.go (100%) rename {internal => backend/internal}/model/persona/domain/repository/repository.go (100%) rename {internal => backend/internal}/model/persona/domain/usecase/usecase.go (100%) rename {internal => backend/internal}/model/persona/repository/mongo.go (100%) rename {internal => backend/internal}/model/persona/usecase/usecase.go (100%) rename {internal => backend/internal}/model/placement/usecase/settings.go (100%) rename {internal => backend/internal}/model/placement_topic/domain/entity/topic.go (100%) rename {internal => backend/internal}/model/placement_topic/domain/repository/repository.go (100%) rename {internal => backend/internal}/model/placement_topic/domain/usecase/usecase.go (100%) rename {internal => backend/internal}/model/placement_topic/repository/mongo.go (100%) rename {internal => backend/internal}/model/placement_topic/usecase/usecase.go (100%) rename {internal => backend/internal}/model/scan_post/domain/entity/outreach_patch.go (100%) rename {internal => backend/internal}/model/scan_post/domain/entity/post.go (100%) rename {internal => backend/internal}/model/scan_post/domain/repository/repository.go (100%) rename {internal => backend/internal}/model/scan_post/domain/usecase/usecase.go (100%) rename {internal => backend/internal}/model/scan_post/repository/mongo.go (100%) rename {internal => backend/internal}/model/scan_post/repository/mongo_priority_filter_test.go (100%) rename {internal => backend/internal}/model/scan_post/repository/mongo_topic_scope_test.go (100%) rename {internal => backend/internal}/model/scan_post/usecase/errors.go (100%) rename {internal => backend/internal}/model/scan_post/usecase/usecase.go (100%) rename {internal => backend/internal}/model/setting/domain/entity/setting.go (100%) rename {internal => backend/internal}/model/setting/domain/repository/setting.go (100%) rename {internal => backend/internal}/model/setting/domain/usecase/setting.go (100%) rename {internal => backend/internal}/model/setting/repository/mongo.go (100%) rename {internal => backend/internal}/model/setting/usecase/usecase.go (100%) rename {internal => backend/internal}/model/threads_account/domain/entity/account.go (100%) rename {internal => backend/internal}/model/threads_account/domain/entity/secrets.go (100%) rename {internal => backend/internal}/model/threads_account/domain/repository/repository.go (100%) rename {internal => backend/internal}/model/threads_account/domain/usecase/usecase.go (100%) rename {internal => backend/internal}/model/threads_account/repository/mongo.go (100%) rename {internal => backend/internal}/model/threads_account/repository/secrets_mongo.go (100%) rename {internal => backend/internal}/model/threads_account/usecase/ai_credentials.go (100%) rename {internal => backend/internal}/model/threads_account/usecase/placement_context.go (100%) rename {internal => backend/internal}/model/threads_account/usecase/placement_context_test.go (100%) rename {internal => backend/internal}/model/threads_account/usecase/publish_credentials.go (100%) rename {internal => backend/internal}/model/threads_account/usecase/usecase.go (100%) rename {internal => backend/internal}/response/request.go (100%) rename {internal => backend/internal}/response/response.go (100%) rename {internal => backend/internal}/svc/service_context.go (100%) rename {internal => backend/internal}/types/types.go (100%) rename {internal => backend/internal}/worker/job/analyze_copy_mission.go (100%) rename {internal => backend/internal}/worker/job/crawler_search.go (100%) rename {internal => backend/internal}/worker/job/expand_graph.go (100%) rename {internal => backend/internal}/worker/job/generate_copy_draft.go (100%) rename {internal => backend/internal}/worker/job/generate_copy_matrix.go (100%) rename {internal => backend/internal}/worker/job/payload_actor.go (100%) rename {internal => backend/internal}/worker/job/placement_scope.go (100%) rename {internal => backend/internal}/worker/job/reaper.go (100%) rename {internal => backend/internal}/worker/job/runner.go (100%) rename {internal => backend/internal}/worker/job/runner_test.go (100%) rename {internal => backend/internal}/worker/job/scan_placement.go (100%) rename {internal => backend/internal}/worker/job/scan_viral.go (100%) rename {internal => backend/internal}/worker/job/scheduler.go (100%) rename {worker => backend/worker}/package.json (100%) rename {worker => backend/worker}/style-8d-worker.ts (100%) rename {worker => backend/worker}/threads-keyword-search-cli.ts (100%) rename {worker => backend/worker}/threads-keyword-search.ts (100%) rename {worker => backend/worker}/threads-profile-scraper.ts (100%) rename {web => frontend}/.gitignore (100%) rename {web => frontend}/index.html (100%) rename {web => frontend}/package-lock.json (100%) rename {web => frontend}/package.json (100%) rename {web => frontend}/public/downloads/haixun-threads-sync.zip (100%) rename {web => frontend}/public/illustrations/island/airplane.svg (100%) rename {web => frontend}/public/illustrations/island/bell.svg (100%) rename {web => frontend}/public/illustrations/island/cloud.svg (100%) rename {web => frontend}/public/illustrations/island/desert-island.svg (100%) rename {web => frontend}/public/illustrations/island/herb.svg (100%) rename {web => frontend}/public/illustrations/island/hut.svg (100%) rename {web => frontend}/public/illustrations/island/magnifying-glass.svg (100%) rename {web => frontend}/public/illustrations/island/passport.svg (100%) rename {web => frontend}/public/illustrations/island/sun.svg (100%) rename {web => frontend}/public/illustrations/island/water-wave.svg (100%) rename {web => frontend}/src/App.tsx (100%) rename {web => frontend}/src/api/client.ts (100%) rename {web => frontend}/src/auth/AuthContext.tsx (100%) rename {web => frontend}/src/components/AcIcon.tsx (100%) rename {web => frontend}/src/components/AccountAiSettings.tsx (100%) rename {web => frontend}/src/components/AccountConnectionMode.tsx (100%) rename {web => frontend}/src/components/AccountDisplayNameSettings.tsx (100%) rename {web => frontend}/src/components/AccountSwitcher.tsx (100%) rename {web => frontend}/src/components/AdminRoute.tsx (100%) rename {web => frontend}/src/components/AppBrandLink.tsx (100%) rename {web => frontend}/src/components/AppSidebar.tsx (100%) rename {web => frontend}/src/components/AuthDecor.tsx (100%) rename {web => frontend}/src/components/AuthShell.tsx (100%) rename {web => frontend}/src/components/BrandProductPicker.tsx (100%) rename {web => frontend}/src/components/BrandProductsPanel.tsx (100%) rename {web => frontend}/src/components/BrandSwitcher.tsx (100%) rename {web => frontend}/src/components/CopyFlowNav.tsx (100%) rename {web => frontend}/src/components/CopyFlowPipeline.tsx (100%) rename {web => frontend}/src/components/CopyMissionActiveJobsCard.tsx (100%) rename {web => frontend}/src/components/CopyMissionAnalyzeJobPanel.tsx (100%) rename {web => frontend}/src/components/CopyMissionDiceButton.tsx (100%) rename {web => frontend}/src/components/CopyMissionDraftCard.tsx (100%) rename {web => frontend}/src/components/CopyMissionJobPanel.tsx (100%) rename {web => frontend}/src/components/CopyMissionResearchEditor.tsx (100%) rename {web => frontend}/src/components/CopyMissionResearchOverview.tsx (100%) rename {web => frontend}/src/components/CopyMissionScanSchedulePanel.tsx (100%) rename {web => frontend}/src/components/CopyMissionSwitcher.tsx (100%) rename {web => frontend}/src/components/CopyPersonaSwitcher.tsx (100%) rename {web => frontend}/src/components/CopySuggestedTagsPicker.tsx (100%) rename {web => frontend}/src/components/CreatePlacementTopicDialog.tsx (100%) rename {web => frontend}/src/components/DevToolsPanel.tsx (100%) rename {web => frontend}/src/components/EvidenceList.tsx (100%) rename {web => frontend}/src/components/ExpandGraphJobPanel.tsx (100%) rename {web => frontend}/src/components/ExpandStrategyField.tsx (100%) rename {web => frontend}/src/components/ExtensionInstallCard.tsx (100%) rename {web => frontend}/src/components/JobMonitor.tsx (100%) rename {web => frontend}/src/components/KnowledgeGraphPanel.tsx (100%) rename {web => frontend}/src/components/KnowledgeLayerRipple.tsx (100%) rename {web => frontend}/src/components/KnowledgeOrbitDiagram.tsx (100%) rename {web => frontend}/src/components/KnowledgeOverviewPanel.tsx (100%) rename {web => frontend}/src/components/Layout.tsx (100%) rename {web => frontend}/src/components/LegacyBrandRouteRedirect.tsx (100%) rename {web => frontend}/src/components/MatrixEntryRoute.tsx (100%) rename {web => frontend}/src/components/MemberMenu.tsx (100%) rename {web => frontend}/src/components/MobileBottomNav.tsx (100%) rename {web => frontend}/src/components/OnboardingBanner.tsx (100%) rename {web => frontend}/src/components/OnboardingGuide.tsx (100%) rename {web => frontend}/src/components/OnboardingRouteGuard.tsx (100%) rename {web => frontend}/src/components/OutreachEntryRoute.tsx (100%) rename {web => frontend}/src/components/PatrolKeywordsEditor.tsx (100%) rename {web => frontend}/src/components/PatrolNodeTagsEditor.tsx (100%) rename {web => frontend}/src/components/PersonaIslandArt.tsx (100%) rename {web => frontend}/src/components/PersonaScanSchedulePanel.tsx (100%) rename {web => frontend}/src/components/PlacementFlowNav.tsx (100%) rename {web => frontend}/src/components/PlacementFlowPipeline.tsx (100%) rename {web => frontend}/src/components/PlacementResearchSettings.tsx (100%) rename {web => frontend}/src/components/PlacementScanJobPanel.tsx (100%) rename {web => frontend}/src/components/ProductContextForm.tsx (100%) rename {web => frontend}/src/components/ProductItemForm.tsx (100%) rename {web => frontend}/src/components/ProtectedRoute.tsx (100%) rename {web => frontend}/src/components/RegisterForm.tsx (100%) rename {web => frontend}/src/components/ReplyPersonaPicker.tsx (100%) rename {web => frontend}/src/components/ResearchMapEditor.tsx (100%) rename {web => frontend}/src/components/ResearchMapOverview.tsx (100%) rename {web => frontend}/src/components/ResearchMapPanel.tsx (100%) rename {web => frontend}/src/components/ScheduleFrequencyPicker.tsx (100%) rename {web => frontend}/src/components/SourceLinkPreview.tsx (100%) rename {web => frontend}/src/components/StringListEditor.tsx (100%) rename {web => frontend}/src/components/Style8DAnalysisMeta.tsx (100%) rename {web => frontend}/src/components/Style8DDimensionEditor.tsx (100%) rename {web => frontend}/src/components/Style8DJobPanel.tsx (100%) rename {web => frontend}/src/components/ThemeToggle.tsx (100%) rename {web => frontend}/src/components/ThreadsAccountWorkspace.tsx (100%) rename {web => frontend}/src/components/ThreadsPostCharMeter.tsx (100%) rename {web => frontend}/src/components/TopicSwitcher.tsx (100%) rename {web => frontend}/src/components/VerifiedAuthorBadge.tsx (100%) rename {web => frontend}/src/components/ViralScanJobPanel.tsx (100%) rename {web => frontend}/src/components/islander/IslanderAvatar.tsx (100%) rename {web => frontend}/src/components/islander/IslanderChatPanel.tsx (100%) rename {web => frontend}/src/components/islander/IslanderCompanion.tsx (100%) rename {web => frontend}/src/components/islander/IslanderMarkdown.tsx (100%) rename {web => frontend}/src/components/ui.tsx (100%) rename {web => frontend}/src/hooks/useActiveBrand.ts (100%) rename {web => frontend}/src/hooks/useActiveCopyPersona.ts (100%) rename {web => frontend}/src/hooks/useActiveTopic.ts (100%) rename {web => frontend}/src/hooks/useIslanderUnlock.ts (100%) rename {web => frontend}/src/index.css (100%) rename {web => frontend}/src/lib/acAssets.ts (100%) rename {web => frontend}/src/lib/aiCredentials.ts (100%) rename {web => frontend}/src/lib/brandContext.ts (100%) rename {web => frontend}/src/lib/connectionMode.ts (100%) rename {web => frontend}/src/lib/copyFlow.ts (100%) rename {web => frontend}/src/lib/copyMissionJobs.ts (100%) rename {web => frontend}/src/lib/easterEggCatalog.ts (100%) rename {web => frontend}/src/lib/extensionInstall.ts (100%) rename {web => frontend}/src/lib/extensionSync.ts (100%) rename {web => frontend}/src/lib/formatDate.ts (100%) rename {web => frontend}/src/lib/islander/IslanderPageContext.tsx (100%) rename {web => frontend}/src/lib/islander/actionExecutor.ts (100%) rename {web => frontend}/src/lib/islander/buildIslanderContext.ts (100%) rename {web => frontend}/src/lib/islander/config.ts (100%) rename {web => frontend}/src/lib/islander/copyActions.ts (100%) rename {web => frontend}/src/lib/islander/handoffStore.ts (100%) rename {web => frontend}/src/lib/islander/index.ts (100%) rename {web => frontend}/src/lib/islander/islanderActions.ts (100%) rename {web => frontend}/src/lib/islander/islanderAgent.ts (100%) rename {web => frontend}/src/lib/islander/outreachActions.ts (100%) rename {web => frontend}/src/lib/islander/pageContextIntent.ts (100%) rename {web => frontend}/src/lib/islander/pageRegistry.ts (100%) rename {web => frontend}/src/lib/islander/pageSnapshot.ts (100%) rename {web => frontend}/src/lib/islander/researchActions.ts (100%) rename {web => frontend}/src/lib/islander/siteGuide.ts (100%) rename {web => frontend}/src/lib/islander/types.ts (100%) rename {web => frontend}/src/lib/islander/unlock.ts (100%) rename {web => frontend}/src/lib/islander/useIslanderPosition.ts (100%) rename {web => frontend}/src/lib/jobStatus.ts (100%) rename {web => frontend}/src/lib/jobTemplate.ts (100%) rename {web => frontend}/src/lib/knowledgeGraph.ts (100%) rename {web => frontend}/src/lib/memberRole.ts (100%) rename {web => frontend}/src/lib/onboarding.ts (100%) rename {web => frontend}/src/lib/pageJobScope.ts (100%) rename {web => frontend}/src/lib/placementFlow.ts (100%) rename {web => frontend}/src/lib/placementResearch.ts (100%) rename {web => frontend}/src/lib/placementTopics.ts (100%) rename {web => frontend}/src/lib/productCatalog.ts (100%) rename {web => frontend}/src/lib/productContext.ts (100%) rename {web => frontend}/src/lib/productMatch.ts (100%) rename {web => frontend}/src/lib/publishPersona.ts (100%) rename {web => frontend}/src/lib/scheduleCatalog.ts (100%) rename {web => frontend}/src/lib/scheduleCron.ts (100%) rename {web => frontend}/src/lib/searchSourceMode.ts (100%) rename {web => frontend}/src/lib/sourcePreview.ts (100%) rename {web => frontend}/src/lib/storage.ts (100%) rename {web => frontend}/src/lib/styleEvidence.ts (100%) rename {web => frontend}/src/lib/styleProfile.ts (100%) rename {web => frontend}/src/lib/threadsAccount.ts (100%) rename {web => frontend}/src/lib/threadsLinks.ts (100%) rename {web => frontend}/src/lib/threadsPostLimits.ts (100%) rename {web => frontend}/src/lib/viralSignals.ts (100%) rename {web => frontend}/src/main.tsx (100%) rename {web => frontend}/src/onboarding/OnboardingContext.tsx (100%) rename {web => frontend}/src/pages/BrandDetailPage.tsx (100%) rename {web => frontend}/src/pages/BrandProductEditPage.tsx (100%) rename {web => frontend}/src/pages/BrandsPage.tsx (100%) rename {web => frontend}/src/pages/CopyMatrixPage.tsx (100%) rename {web => frontend}/src/pages/CopyMissionDetailPage.tsx (100%) rename {web => frontend}/src/pages/DashboardPage.tsx (100%) rename {web => frontend}/src/pages/EasterEggsPage.tsx (100%) rename {web => frontend}/src/pages/JobDetailPage.tsx (100%) rename {web => frontend}/src/pages/JobSchedulesPage.tsx (100%) rename {web => frontend}/src/pages/JobTemplatesPage.tsx (100%) rename {web => frontend}/src/pages/JobsPage.tsx (100%) rename {web => frontend}/src/pages/LoginPage.tsx (100%) rename {web => frontend}/src/pages/PermissionsPage.tsx (100%) rename {web => frontend}/src/pages/PersonaDetailPage.tsx (100%) rename {web => frontend}/src/pages/PersonaMatrixPage.tsx (100%) rename {web => frontend}/src/pages/PersonaOutreachPage.tsx (100%) rename {web => frontend}/src/pages/PersonaResearchPage.tsx (100%) rename {web => frontend}/src/pages/PersonasPage.tsx (100%) rename {web => frontend}/src/pages/PlacementTopicResearchMapPage.tsx (100%) rename {web => frontend}/src/pages/PlacementTopicSettingsPage.tsx (100%) rename {web => frontend}/src/pages/PlacementTopicsPage.tsx (100%) rename {web => frontend}/src/pages/ProfilePage.tsx (100%) rename {web => frontend}/src/pages/SettingsPage.tsx (100%) rename {web => frontend}/src/pages/ThreadsAccountConnectionsPage.tsx (100%) rename {web => frontend}/src/pages/ThreadsAccountPublishPage.tsx (100%) rename {web => frontend}/src/theme/ThemeContext.tsx (100%) rename {web => frontend}/src/threads/ThreadsAccountContext.tsx (100%) rename {web => frontend}/src/types/api.ts (100%) rename {web => frontend}/src/types/brand.ts (100%) rename {web => frontend}/src/types/copyMission.ts (100%) rename {web => frontend}/src/types/placementTopic.ts (100%) rename {web => frontend}/src/vite-env.d.ts (100%) rename {web => frontend}/tsconfig.app.json (100%) rename {web => frontend}/tsconfig.json (100%) rename {web => frontend}/tsconfig.node.json (100%) rename {web => frontend}/vite.config.ts (100%) diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..cae0d5cd8b171ebf082feb40899c59065fbc2153 GIT binary patch literal 8196 zcmeHM&rcLd6n+ILJ+f$npg=U4IEURtqTsTdY&OPWz##ruSevl0pksGWG4#??HR*0Z zAS83x)9%TWC*#?>F(zKT>BXz@vhh#Yy%|rtuYQaSGeg!B8mvmHzBg6htLpl?Uw6MA z03f;5!Z1J|09aTB+8x+5DPmmIhSH*Yx)BNb1I&N}=b!|I$*_TKI*bBF0i%FXz$jo8 z_#70#XSOKT9?yL>YFeX!QQ(VIK-?eFSOsP^Rusxx2R1SVKy1TqSDOLW;Iq6 zN>`jyWe-HV5}jfY<&NWNK}XDLtSD6OK$JTWy)w}m3X!X$Us}?EWEGm$C}0$5R6ykJ zDHw$bxC}ql=I`A1Lhj+H2&f#^C;!TV7C<|A;DQe;K!J&De;4~YGQQrM?XAaUJ@-?u z90qYRH1wLZw6+~O+-|j79o8*pnXfyEo2HxW_F9#2WSIoyH|5__J=Lf6d>TlMM%}3ni^Go3OXV77 z9izm@$Mq#{omlz({?G4Tj5f|w;)Rc3m6!i-wKXSVA!kKgLF~eUC5V7yU?xq&^D%M; zYr?aEe?S7XVP7Uh4{nGyob0-wI0OL(ga{0G_tT0T#c&v|35ObHlR&Q-tqt<43vV9k zAm7E<3Gn8OCc%w|vrnZ}tN`;VL9}2; z8tWabq6l6^p>)AUP(;5>#}PmLVTg8Gk7YGh6ygY$eg6<3k{a`he$Zag>PI)5kae E0k4#!LI3~& literal 0 HcmV?d00001 diff --git a/backend/.DS_Store b/backend/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..438083423bf4bec3d4e830d38faa44c1e6106d17 GIT binary patch literal 8196 zcmeHM&2JM&6n`&ClQrRE0|^9FBnw|EfgnJrR3U^hiD-mSqBsf(UuM0Ym?i5OYj+(J zqDVfsr}oYtz?BnHFFhipUb!Lu1h{g7Hy;k(b)%jN2xg?2-`jb=H?#9wz1L zD4HV%nzIAyaPk)ozoSuy1FD&695XYT6AGH62j&$Wu&B|tRspL(zXCE{2Em60)aJs} z`TZGrZhcTig$+Rs1To_@4^L!xgTNpGKg0Sn!`cIN@QBBtO}GiC=~|>?24sr&-ho3L z2Apc7V>A5dzxdU5+%&&XJg+vxAWo*HUg5yt&=12S&WJPW+$}#4?Q-HJt+?bxx8>E6 z5Mi}zd)4KDwJYxURlyT4;4D%BL5&gR&dq?=M7tzfycVn2vUWH*Cs%PNx3;FIr%vTB zoSWJ{mEZd19NE+7f8O5CITM957Z)q*VS|g`Xbn`*yyjq^zTRnnC#z3~z~3W)=5fhPudV_dazkZ8nKB{;5L^cP(O!tMTU7)?${d^w_Hv_dx7N zA`BNq93+9@t9N43doE#(Ild-n#tqVXzA0VtGK*ruzL=F&FN!=~XQj=%p;u+$JTHkP z5e>t1%ULL1?;1a8!-ax2NaXRZWe^L0d-s*S#06)$T%ovN zTn?OsD{uprVI3aBAMga8!e8(LUc*~>k0W>#PvA+M#!Gk^f5kbxg+4yO03Yi3f$qaT zZpJf!F&TXid!rojQ1CkWWZbY!97>-Cwb>Ldk^f~+3U|3Nw4jkr9+2tY%XJ{2RFerB z5qc1&`6}&u`8imGSyEw!beL85lers|uYlf&VM={Lqp!<3(xa=?-qR3X$zitJa%al)NH49U(LU(sMkBYCKN_8$UhOaFCj<5Qmn`~A1yf0YlPtN94x CS*} Date: Sat, 27 Jun 2026 00:02:06 +0800 Subject: [PATCH 3/8] fix2 --- .DS_Store | Bin 8196 -> 10244 bytes .gitignore | 15 + Makefile | 174 +++++ backend/.DS_Store | Bin 8196 -> 8196 bytes backend/cmd/tool/main.go | 6 +- backend/cmd/worker/main.go | 4 +- backend/etc/gateway.prod.yaml | 20 +- backend/etc/gateway.worker.prod.yaml | 39 ++ backend/etc/gateway.worker.yaml | 17 +- backend/etc/gateway.yaml | 13 +- backend/gateway.go | 2 +- backend/internal/bootstrap/init.go | 28 + backend/internal/config/config.go | 14 +- backend/internal/library/crypto/aesgcm.go | 99 +++ backend/internal/library/redis/client.go | 5 +- backend/internal/logic/setting/authz.go | 41 ++ .../logic/setting/delete_setting_logic.go | 3 + .../logic/setting/get_setting_logic.go | 3 + .../logic/setting/list_settings_logic.go | 3 + .../logic/setting/upsert_setting_logic.go | 3 + .../internal/middleware/auth_middleware.go | 22 - .../middleware/permissionrbac_middleware.go | 9 +- .../middleware/workersecret_middleware.go | 16 +- backend/internal/model/auth/usecase/token.go | 12 +- .../model/permission/usecase/usecase_test.go | 2 +- .../model/placement/usecase/settings.go | 44 +- .../repository/secrets_mongo.go | 27 +- .../threads_account/usecase/ai_credentials.go | 51 +- .../model/threads_account/usecase/usecase.go | 4 + backend/internal/svc/service_context.go | 22 +- backend/internal/worker/job/runner.go | 22 +- backend/worker/package-lock.json | 593 ++++++++++++++++++ frontend/src/App.tsx | 3 + frontend/src/api/client.ts | 123 ++-- frontend/src/auth/AuthContext.tsx | 20 +- frontend/src/components/ErrorBoundary.tsx | 50 ++ frontend/src/components/JobMonitor.tsx | 25 +- .../components/islander/IslanderCompanion.tsx | 15 +- .../components/islander/IslanderMarkdown.tsx | 3 +- frontend/src/lib/extensionSync.ts | 9 +- frontend/src/lib/islander/actionExecutor.ts | 16 + frontend/src/lib/islander/config.ts | 12 + frontend/src/lib/storage.ts | 3 + infra/.env.example | 12 + infra/README.md | 103 +++ infra/docker-compose.yml | 42 ++ infra/etc/haixun.env.example | 23 + infra/nginx/haixun.conf | 56 ++ infra/systemd/haixun-gateway.service | 25 + infra/systemd/haixun-node-worker.service | 23 + infra/systemd/haixun-worker.service | 23 + 51 files changed, 1762 insertions(+), 137 deletions(-) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 backend/etc/gateway.worker.prod.yaml create mode 100644 backend/internal/library/crypto/aesgcm.go create mode 100644 backend/internal/logic/setting/authz.go delete mode 100644 backend/internal/middleware/auth_middleware.go create mode 100644 backend/worker/package-lock.json create mode 100644 frontend/src/components/ErrorBoundary.tsx create mode 100644 infra/.env.example create mode 100644 infra/README.md create mode 100644 infra/docker-compose.yml create mode 100644 infra/etc/haixun.env.example create mode 100644 infra/nginx/haixun.conf create mode 100644 infra/systemd/haixun-gateway.service create mode 100644 infra/systemd/haixun-node-worker.service create mode 100644 infra/systemd/haixun-worker.service diff --git a/.DS_Store b/.DS_Store index cae0d5cd8b171ebf082feb40899c59065fbc2153..8d07aef3e7f58514f024624d091b16e033258c3c 100644 GIT binary patch delta 317 zcmZp1XbF&DU|?W$DortDU{C-uIe-{M3-C-V6q~50$SAfkU^hRb*km37O~!eX%>*=A zoq*DMn^Od4G4pN$G5>=B1HTP frontend/dist + cd $(FRONTEND_DIR) && npm ci && npm run build + +.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)" + +# ============================================================ +# PROD (部署) +# ============================================================ + +.PHONY: prod-infra +prod-infra: $(INFRA_DIR)/.env ## [prod] 起 Mongo + Redis (docker,背景) + $(COMPOSE) up -d + +.PHONY: prod-infra-down +prod-infra-down: ## [prod] 停掉 Mongo + Redis + $(COMPOSE) down + +.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 + +.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" + +# ============================================================ +# 驗證 / 維護 +# ============================================================ + +.PHONY: tidy +tidy: ## go mod tidy + cd $(BACKEND_DIR) && go mod tidy + +.PHONY: fmt +fmt: ## gofmt 後端 + cd $(BACKEND_DIR) && gofmt -w . + +.PHONY: test +test: ## 跑後端測試 + cd $(BACKEND_DIR) && go test ./... + +.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 index 438083423bf4bec3d4e830d38faa44c1e6106d17..3397521d4d5052c563c944fb23e80fffc04c6401 100644 GIT binary patch delta 142 zcmZp1XmQw}D!}T*z`&q4SxZ1khhYF=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/frontend/src/App.tsx b/frontend/src/App.tsx index 737ed96..f897e37 100644 --- a/frontend/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/client.ts b/frontend/src/api/client.ts index 10a4524..1ab0ffa 100644 --- a/frontend/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/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx index 3007fc9..54690fb 100644 --- a/frontend/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/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/frontend/src/components/JobMonitor.tsx b/frontend/src/components/JobMonitor.tsx index b32033c..be1d83b 100644 --- a/frontend/src/components/JobMonitor.tsx +++ b/frontend/src/components/JobMonitor.tsx @@ -1,7 +1,7 @@ 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 { EXPAND_GRAPH_PIPELINE_STEPS, PLACEMENT_SCAN_PIPELINE_STEPS } from '../lib/knowledgeGraph' @@ -347,11 +347,28 @@ export function JobMonitor() { } }, []) + 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]) + // 有進行中任務時每 3 秒刷新;否則放慢到 20 秒(仍能撈到別處啟動的任務)。 + // 分頁切到背景時暫停輪詢,避免無謂的流量與後端負載。 + const intervalMs = hasActiveJob ? 3000 : 20000 + const timer = window.setInterval(() => { + if (document.visibilityState === 'hidden') return + load().catch(() => undefined) + }, intervalMs) + + const onVisible = () => { + if (document.visibilityState === 'visible') load().catch(() => undefined) + } + document.addEventListener('visibilitychange', onVisible) + + return () => { + window.clearInterval(timer) + document.removeEventListener('visibilitychange', onVisible) + } + }, [load, hasActiveJob]) const syncClampedPosition = useCallback(() => { setPosition((prev) => { diff --git a/frontend/src/components/islander/IslanderCompanion.tsx b/frontend/src/components/islander/IslanderCompanion.tsx index 2ee8f84..7b559ea 100644 --- a/frontend/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/frontend/src/components/islander/IslanderMarkdown.tsx b/frontend/src/components/islander/IslanderMarkdown.tsx index 6c418da..bf764fd 100644 --- a/frontend/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/frontend/src/lib/extensionSync.ts b/frontend/src/lib/extensionSync.ts index d241c7c..51329a8 100644 --- a/frontend/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/frontend/src/lib/islander/actionExecutor.ts b/frontend/src/lib/islander/actionExecutor.ts index 618fee5..2fbe37f 100644 --- a/frontend/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/frontend/src/lib/islander/config.ts b/frontend/src/lib/islander/config.ts index cd9262e..29bcb8c 100644 --- a/frontend/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/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index a86998d..b1d96dd 100644 --- a/frontend/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/infra/.env.example b/infra/.env.example new file mode 100644 index 0000000..d0a5704 --- /dev/null +++ b/infra/.env.example @@ -0,0 +1,12 @@ +# Infra docker compose 環境變數範本 +# 複製成 infra/.env 後填入實際值;.env 不要 commit。 + +# --- Mongo --- +MONGO_PORT=27017 +MONGO_ROOT_USER=haixun +MONGO_ROOT_PASSWORD=change-me-mongo-pass +MONGO_DATABASE=haixun + +# --- Redis --- +REDIS_PORT=6379 +REDIS_PASSWORD=change-me-redis-pass 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 -- 2.40.1 From 29d0aca295fe47275bbd34196fea86804058c84a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Sat, 27 Jun 2026 00:15:29 +0800 Subject: [PATCH 4/8] fix2 --- infra/.env.example | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/infra/.env.example b/infra/.env.example index d0a5704..4780b12 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -4,9 +4,17 @@ # --- Mongo --- MONGO_PORT=27017 MONGO_ROOT_USER=haixun -MONGO_ROOT_PASSWORD=change-me-mongo-pass +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 -- 2.40.1 From ef6281ef831981f81ce33506ae4d22a60bcb41fb Mon Sep 17 00:00:00 2001 From: wang Date: Fri, 26 Jun 2026 16:35:47 +0000 Subject: [PATCH 5/8] fix --- Makefile | 55 +++++++++++++++++++++++++++++++++ frontend/package-lock.json | 63 -------------------------------------- 2 files changed, 55 insertions(+), 63 deletions(-) diff --git a/Makefile b/Makefile index 29659b5..249a020 100644 --- a/Makefile +++ b/Makefile @@ -86,6 +86,61 @@ dev: ## [dev] 顯示本機開發要開的終端 @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 "" + +.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)" + # ============================================================ # BUILD (prod 產物) # ============================================================ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 959380f..692517f 100644 --- a/frontend/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": [ -- 2.40.1 From c44d6c2bfca602bfcab0059254754f2fcb5b48da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Sat, 27 Jun 2026 22:49:17 +0800 Subject: [PATCH 6/8] fix2 --- backend/generate/api/threads_account.api | 25 +++ backend/internal/handler/routes.go | 5 + ...eads_account_ai_provider_models_handler.go | 30 +++ ...hreads_account_ai_provider_models_logic.go | 58 +++++ .../threads_account/domain/usecase/usecase.go | 1 + .../threads_account/usecase/ai_credentials.go | 23 ++ .../usecase/connection_prefs_test.go | 47 ++++ .../model/threads_account/usecase/usecase.go | 26 ++- backend/internal/types/types.go | 22 ++ frontend/src/api/ai.ts | 26 +++ frontend/src/components/AccountAiSettings.tsx | 203 ++++++++++++++--- .../src/components/AccountConnectionMode.tsx | 11 +- .../components/CreatePlacementTopicDialog.tsx | 33 ++- frontend/src/components/JobMonitor.tsx | 118 ++++------ .../src/components/OnboardingRouteGuard.tsx | 4 +- frontend/src/lib/aiCredentials.ts | 18 ++ frontend/src/lib/islander/researchActions.ts | 3 + frontend/src/lib/jobMonitorRefresh.ts | 6 + frontend/src/lib/jobMonitorUi.ts | 206 ++++++++++++++++++ frontend/src/lib/searchSourceMode.ts | 1 + frontend/src/onboarding/OnboardingContext.tsx | 17 +- frontend/src/pages/CopyMatrixPage.tsx | 2 + frontend/src/pages/CopyMissionDetailPage.tsx | 5 + frontend/src/pages/PersonaDetailPage.tsx | 2 + frontend/src/pages/PersonaResearchPage.tsx | 3 + .../pages/PlacementTopicResearchMapPage.tsx | 3 + .../src/pages/PlacementTopicSettingsPage.tsx | 2 + frontend/src/pages/PlacementTopicsPage.tsx | 55 +++-- frontend/src/types/api.ts | 26 +++ 29 files changed, 832 insertions(+), 149 deletions(-) create mode 100644 backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go create mode 100644 backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go create mode 100644 backend/internal/model/threads_account/usecase/connection_prefs_test.go create mode 100644 frontend/src/api/ai.ts create mode 100644 frontend/src/lib/jobMonitorRefresh.ts create mode 100644 frontend/src/lib/jobMonitorUi.ts diff --git a/backend/generate/api/threads_account.api b/backend/generate/api/threads_account.api index 51b8253..93396a3 100644 --- a/backend/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/backend/internal/handler/routes.go b/backend/internal/handler/routes.go index df1dd67..cbc3596 100644 --- a/backend/internal/handler/routes.go +++ b/backend/internal/handler/routes.go @@ -749,6 +749,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/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..a461172 --- /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) + } +} \ No newline at end of file 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..1b9e3ec --- /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 +} \ No newline at end of file diff --git a/backend/internal/model/threads_account/domain/usecase/usecase.go b/backend/internal/model/threads_account/domain/usecase/usecase.go index 184378a..4273378 100644 --- a/backend/internal/model/threads_account/domain/usecase/usecase.go +++ b/backend/internal/model/threads_account/domain/usecase/usecase.go @@ -142,6 +142,7 @@ 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) diff --git a/backend/internal/model/threads_account/usecase/ai_credentials.go b/backend/internal/model/threads_account/usecase/ai_credentials.go index 19fbc1c..ec0bca9 100644 --- a/backend/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, 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..a2d0e25 --- /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 +} \ No newline at end of file diff --git a/backend/internal/model/threads_account/usecase/usecase.go b/backend/internal/model/threads_account/usecase/usecase.go index 45a6ab0..33525b9 100644 --- a/backend/internal/model/threads_account/usecase/usecase.go +++ b/backend/internal/model/threads_account/usecase/usecase.go @@ -345,7 +345,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, @@ -368,7 +368,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 @@ -388,10 +394,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) } @@ -399,15 +408,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/backend/internal/types/types.go b/backend/internal/types/types.go index 47c7f66..6dac79a 100644 --- a/backend/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"` 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/frontend/src/components/AccountAiSettings.tsx b/frontend/src/components/AccountAiSettings.tsx index 9607a2e..00fa9e5 100644 --- a/frontend/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/frontend/src/components/AccountConnectionMode.tsx b/frontend/src/components/AccountConnectionMode.tsx index 4001505..1ee127e 100644 --- a/frontend/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/frontend/src/components/CreatePlacementTopicDialog.tsx b/frontend/src/components/CreatePlacementTopicDialog.tsx index d01e11a..4fa835d 100644 --- a/frontend/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/frontend/src/components/JobMonitor.tsx b/frontend/src/components/JobMonitor.tsx index be1d83b..25ae6b8 100644 --- a/frontend/src/components/JobMonitor.tsx +++ b/frontend/src/components/JobMonitor.tsx @@ -3,9 +3,16 @@ import { Link } from 'react-router-dom' import { api } from '../api/client' 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,7 +307,7 @@ export function JobMonitor() { } catch { // 登入初期或 gateway 重啟時保持安靜,避免干擾主要操作。 } finally { - setLoading(false) + if (!opts?.silent) setLoading(false) } }, []) @@ -351,22 +315,26 @@ export function JobMonitor() { useEffect(() => { load().catch(() => undefined) - // 有進行中任務時每 3 秒刷新;否則放慢到 20 秒(仍能撈到別處啟動的任務)。 - // 分頁切到背景時暫停輪詢,避免無謂的流量與後端負載。 - const intervalMs = hasActiveJob ? 3000 : 20000 + // 有進行中任務時 2 秒刷新;閒置時 8 秒(原 20 秒太慢,新任務會 lag 很久才出現)。 + const intervalMs = hasActiveJob ? 2000 : 8000 const timer = window.setInterval(() => { if (document.visibilityState === 'hidden') return - load().catch(() => undefined) + load({ silent: true }).catch(() => undefined) }, intervalMs) const onVisible = () => { - if (document.visibilityState === 'visible') load().catch(() => undefined) + 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]) @@ -443,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 = @@ -467,9 +435,7 @@ export function JobMonitor() {
{activeCount > 0 && !expanded && current ? (
-

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

+

{jobActivitySummary(current)}

) : null} diff --git a/frontend/src/components/OnboardingRouteGuard.tsx b/frontend/src/components/OnboardingRouteGuard.tsx index 1b1e433..d943d69 100644 --- a/frontend/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/frontend/src/lib/aiCredentials.ts b/frontend/src/lib/aiCredentials.ts index bf3694b..28fa018 100644 --- a/frontend/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/frontend/src/lib/islander/researchActions.ts b/frontend/src/lib/islander/researchActions.ts index 0363156..bd66893 100644 --- a/frontend/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/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/frontend/src/lib/searchSourceMode.ts b/frontend/src/lib/searchSourceMode.ts index 64fad46..4a04340 100644 --- a/frontend/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/frontend/src/onboarding/OnboardingContext.tsx b/frontend/src/onboarding/OnboardingContext.tsx index 44307c0..c69a4fb 100644 --- a/frontend/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/frontend/src/pages/CopyMatrixPage.tsx b/frontend/src/pages/CopyMatrixPage.tsx index a868244..5dc842a 100644 --- a/frontend/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/frontend/src/pages/CopyMissionDetailPage.tsx b/frontend/src/pages/CopyMissionDetailPage.tsx index 7fd6e4a..625be84 100644 --- a/frontend/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/frontend/src/pages/PersonaDetailPage.tsx b/frontend/src/pages/PersonaDetailPage.tsx index 7aa065d..9ea7180 100644 --- a/frontend/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/frontend/src/pages/PersonaResearchPage.tsx b/frontend/src/pages/PersonaResearchPage.tsx index e6647f0..716d8e7 100644 --- a/frontend/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/frontend/src/pages/PlacementTopicResearchMapPage.tsx b/frontend/src/pages/PlacementTopicResearchMapPage.tsx index 59935c7..33b0a2d 100644 --- a/frontend/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/frontend/src/pages/PlacementTopicSettingsPage.tsx b/frontend/src/pages/PlacementTopicSettingsPage.tsx index 8cf826c..78a0bd7 100644 --- a/frontend/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/frontend/src/pages/PlacementTopicsPage.tsx b/frontend/src/pages/PlacementTopicsPage.tsx index 6842c0d..e18617a 100644 --- a/frontend/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/frontend/src/types/api.ts b/frontend/src/types/api.ts index 11f2a10..8290840 100644 --- a/frontend/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 -- 2.40.1 From ae89e0451ec888a0df46f69e686994fcfa3d9036 Mon Sep 17 00:00:00 2001 From: wang Date: Sat, 27 Jun 2026 15:00:10 +0000 Subject: [PATCH 7/8] add script --- start-prod.sh | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++ status-prod.sh | 20 +++++++++++++++++ stop-prod.sh | 6 +++++ 3 files changed, 85 insertions(+) create mode 100755 start-prod.sh create mode 100755 status-prod.sh create mode 100755 stop-prod.sh 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" -- 2.40.1 From 9ab5b6671debd39e0aabcffc708d220bbab25c20 Mon Sep 17 00:00:00 2001 From: wang Date: Sat, 27 Jun 2026 16:03:28 +0000 Subject: [PATCH 8/8] add search and post --- backend/docs/phase1-methods.md | 176 ++++++++++++++ .../handler/crm/create_crm_contact_handler.go | 29 +++ .../handler/crm/delete_crm_contact_handler.go | 25 ++ .../handler/crm/get_crm_contact_handler.go | 25 ++ .../handler/crm/list_crm_contacts_handler.go | 25 ++ .../handler/crm/update_crm_contact_handler.go | 25 ++ backend/internal/handler/routes.go | 50 ++++ .../handler/scan_post/lookalike_handler.go | 29 +++ ...eads_account_ai_provider_models_handler.go | 2 +- backend/internal/library/embedding/client.go | 135 +++++++++++ .../internal/library/placement/dual_track.go | 106 +++++++-- .../library/ratelimit/token_bucket.go | 62 +++++ backend/internal/library/threadsapi/media.go | 97 ++++++++ backend/internal/logic/crm/actor.go | 17 ++ .../logic/crm/create_crm_contact_logic.go | 46 ++++ .../logic/crm/delete_crm_contact_logic.go | 29 +++ .../logic/crm/get_crm_contact_logic.go | 34 +++ .../logic/crm/list_crm_contacts_logic.go | 54 +++++ backend/internal/logic/crm/mapper.go | 48 ++++ .../logic/crm/update_crm_contact_logic.go | 42 ++++ .../logic/scan_post/lookalike_logic.go | 126 ++++++++++ ...hreads_account_ai_provider_models_logic.go | 2 +- .../crm_contact/domain/entity/contact.go | 30 +++ .../domain/repository/repository.go | 25 ++ .../crm_contact/domain/usecase/usecase.go | 47 ++++ .../model/crm_contact/repository/mongo.go | 217 ++++++++++++++++++ .../model/crm_contact/usecase/usecase.go | 176 ++++++++++++++ .../internal/model/job/domain/usecase/job.go | 2 + backend/internal/model/job/usecase/usecase.go | 78 ++++++- .../domain/entity/analytics.go | 29 +++ .../domain/repository/repository.go | 15 ++ .../domain/usecase/usecase.go | 24 ++ .../publish_analytics/repository/mongo.go | 125 ++++++++++ .../publish_analytics/usecase/usecase.go | 105 +++++++++ .../model/scan_post/domain/entity/post.go | 75 +++--- .../scan_post/domain/repository/repository.go | 1 + .../model/scan_post/domain/usecase/usecase.go | 68 +++--- .../model/scan_post/repository/mongo.go | 3 + .../model/scan_post/usecase/usecase.go | 174 ++++++++------ .../domain/repository/repository.go | 1 + .../threads_account/domain/usecase/usecase.go | 8 + .../repository/secrets_mongo.go | 27 +++ .../threads_account/usecase/ai_credentials.go | 2 +- .../usecase/connection_prefs_test.go | 2 +- .../model/threads_account/usecase/usecase.go | 21 ++ backend/internal/svc/service_context.go | 113 ++++++--- backend/internal/types/types.go | 91 ++++++++ backend/internal/worker/job/expand_graph.go | 20 ++ .../internal/worker/job/publish_analytics.go | 81 +++++++ .../worker/job/refresh_threads_token.go | 63 +++++ 50 files changed, 2592 insertions(+), 215 deletions(-) create mode 100644 backend/docs/phase1-methods.md create mode 100644 backend/internal/handler/crm/create_crm_contact_handler.go create mode 100644 backend/internal/handler/crm/delete_crm_contact_handler.go create mode 100644 backend/internal/handler/crm/get_crm_contact_handler.go create mode 100644 backend/internal/handler/crm/list_crm_contacts_handler.go create mode 100644 backend/internal/handler/crm/update_crm_contact_handler.go create mode 100644 backend/internal/handler/scan_post/lookalike_handler.go create mode 100644 backend/internal/library/embedding/client.go create mode 100644 backend/internal/library/ratelimit/token_bucket.go create mode 100644 backend/internal/library/threadsapi/media.go create mode 100644 backend/internal/logic/crm/actor.go create mode 100644 backend/internal/logic/crm/create_crm_contact_logic.go create mode 100644 backend/internal/logic/crm/delete_crm_contact_logic.go create mode 100644 backend/internal/logic/crm/get_crm_contact_logic.go create mode 100644 backend/internal/logic/crm/list_crm_contacts_logic.go create mode 100644 backend/internal/logic/crm/mapper.go create mode 100644 backend/internal/logic/crm/update_crm_contact_logic.go create mode 100644 backend/internal/logic/scan_post/lookalike_logic.go create mode 100644 backend/internal/model/crm_contact/domain/entity/contact.go create mode 100644 backend/internal/model/crm_contact/domain/repository/repository.go create mode 100644 backend/internal/model/crm_contact/domain/usecase/usecase.go create mode 100644 backend/internal/model/crm_contact/repository/mongo.go create mode 100644 backend/internal/model/crm_contact/usecase/usecase.go create mode 100644 backend/internal/model/publish_analytics/domain/entity/analytics.go create mode 100644 backend/internal/model/publish_analytics/domain/repository/repository.go create mode 100644 backend/internal/model/publish_analytics/domain/usecase/usecase.go create mode 100644 backend/internal/model/publish_analytics/repository/mongo.go create mode 100644 backend/internal/model/publish_analytics/usecase/usecase.go create mode 100644 backend/internal/worker/job/publish_analytics.go create mode 100644 backend/internal/worker/job/refresh_threads_token.go 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/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/backend/internal/handler/routes.go b/backend/internal/handler/routes.go index cbc3596..a2a8e3e 100644 --- a/backend/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}, 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/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 index a461172..e7ebf6a 100644 --- 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 @@ -27,4 +27,4 @@ func ListThreadsAccountAiProviderModelsHandler(svcCtx *svc.ServiceContext) http. data, err := l.ListThreadsAccountAiProviderModels(&req) response.Write(r.Context(), w, data, err) } -} \ No newline at end of file +} 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/backend/internal/library/placement/dual_track.go b/backend/internal/library/placement/dual_track.go index 53d4715..4f37509 100644 --- a/backend/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/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/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/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/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/threads_account/list_threads_account_ai_provider_models_logic.go b/backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go index 1b9e3ec..41556bb 100644 --- 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 @@ -55,4 +55,4 @@ func (l *ListThreadsAccountAiProviderModelsLogic) ListThreadsAccountAiProviderMo Streams: result.Streams, Error: result.Error, }, nil -} \ No newline at end of file +} 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/backend/internal/model/job/domain/usecase/job.go b/backend/internal/model/job/domain/usecase/job.go index 6be688b..55564a0 100644 --- a/backend/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/backend/internal/model/job/usecase/usecase.go b/backend/internal/model/job/usecase/usecase.go index 8aafe34..847ece2 100644 --- a/backend/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/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/backend/internal/model/scan_post/domain/entity/post.go b/backend/internal/model/scan_post/domain/entity/post.go index dbbd81f..1f0921f 100644 --- a/backend/internal/model/scan_post/domain/entity/post.go +++ b/backend/internal/model/scan_post/domain/entity/post.go @@ -15,40 +15,47 @@ type ScanReply struct { } 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"` + 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 ( diff --git a/backend/internal/model/scan_post/domain/repository/repository.go b/backend/internal/model/scan_post/domain/repository/repository.go index 5940b62..8b9ac28 100644 --- a/backend/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/backend/internal/model/scan_post/domain/usecase/usecase.go b/backend/internal/model/scan_post/domain/usecase/usecase.go index 57186b8..0e0ab16 100644 --- a/backend/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/backend/internal/model/scan_post/repository/mongo.go b/backend/internal/model/scan_post/repository/mongo.go index 0531d31..5c955ff 100644 --- a/backend/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/backend/internal/model/scan_post/usecase/usecase.go b/backend/internal/model/scan_post/usecase/usecase.go index 0669f88..05988f8 100644 --- a/backend/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/backend/internal/model/threads_account/domain/repository/repository.go b/backend/internal/model/threads_account/domain/repository/repository.go index 3c31e1e..3cafd6b 100644 --- a/backend/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/backend/internal/model/threads_account/domain/usecase/usecase.go b/backend/internal/model/threads_account/domain/usecase/usecase.go index 4273378..df9f5c7 100644 --- a/backend/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 @@ -147,4 +154,5 @@ type UseCase interface { 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/backend/internal/model/threads_account/repository/secrets_mongo.go b/backend/internal/model/threads_account/repository/secrets_mongo.go index edc75e5..11fb167 100644 --- a/backend/internal/model/threads_account/repository/secrets_mongo.go +++ b/backend/internal/model/threads_account/repository/secrets_mongo.go @@ -92,3 +92,30 @@ func (r *secretsMongoRepository) SaveBrowserStorageState(ctx context.Context, ac 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") + } + now := clock.NowUnixNano() + var out entity.Secrets + err := r.collection.FindOneAndUpdate( + ctx, + bson.M{"_id": accountID}, + bson.M{ + "$set": bson.M{ + "api_access_token": accessToken, + "api_token_expires_at": expiresAt, + "update_at": now, + }, + "$setOnInsert": bson.M{"_id": accountID}, + }, + options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After), + ).Decode(&out) + if err != nil { + return nil, err + } + out.APIAccessToken = accessToken + out.APITokenExpiresAt = expiresAt + return &out, nil +} diff --git a/backend/internal/model/threads_account/usecase/ai_credentials.go b/backend/internal/model/threads_account/usecase/ai_credentials.go index ec0bca9..6b1e1ea 100644 --- a/backend/internal/model/threads_account/usecase/ai_credentials.go +++ b/backend/internal/model/threads_account/usecase/ai_credentials.go @@ -105,7 +105,7 @@ func (u *threadsAccountUseCase) ResolveAiProviderAPIKey( } apiKey := strings.TrimSpace(stored.ApiKeys[provider]) if apiKey == "" { - return "", app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 "+provider+" API key") + return "", app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 " + provider + " API key") } return apiKey, nil } diff --git a/backend/internal/model/threads_account/usecase/connection_prefs_test.go b/backend/internal/model/threads_account/usecase/connection_prefs_test.go index a2d0e25..073aafe 100644 --- a/backend/internal/model/threads_account/usecase/connection_prefs_test.go +++ b/backend/internal/model/threads_account/usecase/connection_prefs_test.go @@ -44,4 +44,4 @@ func TestNormalizeConnectionPrefsKeepsCrawlerModeInFormalMode(t *testing.T) { func strPtr(v string) *string { return &v -} \ No newline at end of file +} diff --git a/backend/internal/model/threads_account/usecase/usecase.go b/backend/internal/model/threads_account/usecase/usecase.go index 33525b9..297fd34 100644 --- a/backend/internal/model/threads_account/usecase/usecase.go +++ b/backend/internal/model/threads_account/usecase/usecase.go @@ -277,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 { diff --git a/backend/internal/svc/service_context.go b/backend/internal/svc/service_context.go index f9311de..3b807b9 100644 --- a/backend/internal/svc/service_context.go +++ b/backend/internal/svc/service_context.go @@ -30,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" @@ -51,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" @@ -71,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 @@ -189,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 { @@ -266,28 +280,42 @@ func NewServiceContext(c config.Config) *ServiceContext { 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() @@ -357,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) } diff --git a/backend/internal/types/types.go b/backend/internal/types/types.go index 6dac79a..1ac15fa 100644 --- a/backend/internal/types/types.go +++ b/backend/internal/types/types.go @@ -1448,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/backend/internal/worker/job/expand_graph.go b/backend/internal/worker/job/expand_graph.go index 4ea3d4b..c0df628 100644 --- a/backend/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/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/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 +} -- 2.40.1