fix2
This commit is contained in:
parent
3342f7a344
commit
ffd6a31d6f
|
|
@ -0,0 +1,15 @@
|
||||||
|
# --- secrets / env(不要 commit 實值)---
|
||||||
|
infra/.env
|
||||||
|
infra/etc/haixun.env
|
||||||
|
/opt/haixun/
|
||||||
|
|
||||||
|
# --- build 產物 ---
|
||||||
|
backend/bin/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# --- 依賴 ---
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# --- 其他 ---
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
# 巡樓 monorepo Makefile
|
||||||
|
# 兩種模式:
|
||||||
|
# dev - 本機開發(docker 起 Mongo/Redis,go run / vite dev)
|
||||||
|
# prod - 建置產物(前端 dist + linux Go binary),並可部署成 systemd 服務 + nginx
|
||||||
|
#
|
||||||
|
# 常用:
|
||||||
|
# make dev-infra # 起本機 Mongo/Redis
|
||||||
|
# make dev-backend # 跑 gateway (:8890)
|
||||||
|
# make dev-frontend # 跑前端 (:5173,proxy 到 :8890)
|
||||||
|
# make build # 產出 frontend/dist + backend/bin/{gateway,worker}
|
||||||
|
# sudo make install # 部署到目標主機(見 infra/README.md)
|
||||||
|
|
||||||
|
SHELL := /bin/bash
|
||||||
|
|
||||||
|
# --- 路徑 ---
|
||||||
|
BACKEND_DIR := backend
|
||||||
|
FRONTEND_DIR := frontend
|
||||||
|
INFRA_DIR := infra
|
||||||
|
BIN_DIR := $(BACKEND_DIR)/bin
|
||||||
|
|
||||||
|
# --- 部署目標(install 用)---
|
||||||
|
DEPLOY_ROOT := /opt/haixun
|
||||||
|
WEB_ROOT := /var/www/haixun
|
||||||
|
|
||||||
|
# --- 交叉編譯目標(在 mac 上 build 給 linux 主機)---
|
||||||
|
GOOS ?= linux
|
||||||
|
GOARCH ?= amd64
|
||||||
|
|
||||||
|
# --- docker compose ---
|
||||||
|
COMPOSE := docker compose -f $(INFRA_DIR)/docker-compose.yml --env-file $(INFRA_DIR)/.env
|
||||||
|
|
||||||
|
# --- dev 用 worker secret(對應 etc/gateway.yaml 的 InternalWorker.Secret)---
|
||||||
|
DEV_WORKER_SECRET := haixun-dev-worker-secret
|
||||||
|
DEV_BACKEND_URL := http://127.0.0.1:8890
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## 顯示可用指令
|
||||||
|
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
||||||
|
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# DEV
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
$(INFRA_DIR)/.env:
|
||||||
|
@test -f $(INFRA_DIR)/.env || (cp $(INFRA_DIR)/.env.example $(INFRA_DIR)/.env && echo "已從 .env.example 建立 $(INFRA_DIR)/.env,請視需要修改密碼")
|
||||||
|
|
||||||
|
.PHONY: dev-infra
|
||||||
|
dev-infra: $(INFRA_DIR)/.env ## [dev] 起本機 Mongo + Redis (docker)
|
||||||
|
$(COMPOSE) up -d
|
||||||
|
$(COMPOSE) ps
|
||||||
|
|
||||||
|
.PHONY: dev-infra-down
|
||||||
|
dev-infra-down: ## [dev] 停掉本機 Mongo + Redis
|
||||||
|
$(COMPOSE) down
|
||||||
|
|
||||||
|
.PHONY: dev-backend
|
||||||
|
dev-backend: ## [dev] 跑 gateway API (:8890)
|
||||||
|
cd $(BACKEND_DIR) && go run . -f etc/gateway.yaml
|
||||||
|
|
||||||
|
.PHONY: dev-worker
|
||||||
|
dev-worker: ## [dev] 跑 Go job worker (:8891)
|
||||||
|
cd $(BACKEND_DIR) && go run ./cmd/worker -f etc/gateway.worker.yaml
|
||||||
|
|
||||||
|
.PHONY: dev-node-worker
|
||||||
|
dev-node-worker: ## [dev] 跑 Node playwright worker (style-8d)
|
||||||
|
cd $(BACKEND_DIR)/worker && npm install && \
|
||||||
|
HAIXUN_WORKER_SECRET=$(DEV_WORKER_SECRET) HAIXUN_BACKEND_URL=$(DEV_BACKEND_URL) npm run style-8d
|
||||||
|
|
||||||
|
.PHONY: dev-frontend
|
||||||
|
dev-frontend: ## [dev] 跑前端 dev server (:5173)
|
||||||
|
cd $(FRONTEND_DIR) && npm install && npm run dev
|
||||||
|
|
||||||
|
.PHONY: dev-init
|
||||||
|
dev-init: ## [dev] 初始化 DB(索引/權限)並建立 admin 帳號(可用 INIT_ADMIN_EMAIL / INIT_ADMIN_PASSWORD 覆寫)
|
||||||
|
cd $(BACKEND_DIR) && go run ./cmd/tool init -f etc/gateway.yaml
|
||||||
|
|
||||||
|
.PHONY: dev
|
||||||
|
dev: ## [dev] 顯示本機開發要開的終端
|
||||||
|
@echo "本機開發請分開幾個終端執行:"
|
||||||
|
@echo " 1) make dev-infra # Mongo/Redis"
|
||||||
|
@echo " 2) make dev-backend # gateway :8890"
|
||||||
|
@echo " 3) make dev-worker # go worker(可選)"
|
||||||
|
@echo " 4) make dev-node-worker # node worker(需 style-8d 時)"
|
||||||
|
@echo " 5) make dev-frontend # 前端 :5173"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# BUILD (prod 產物)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: build-frontend build-backend ## [prod] 建置前端 dist 與 Go binary
|
||||||
|
|
||||||
|
.PHONY: build-frontend
|
||||||
|
build-frontend: ## [prod] 前端靜態建置 (tsc + vite) -> 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"
|
||||||
Binary file not shown.
|
|
@ -36,15 +36,15 @@ func runInit(args []string) error {
|
||||||
fs := flag.NewFlagSet("init", flag.ExitOnError)
|
fs := flag.NewFlagSet("init", flag.ExitOnError)
|
||||||
configFile := fs.String("f", "etc/gateway.yaml", "config file")
|
configFile := fs.String("f", "etc/gateway.yaml", "config file")
|
||||||
tenantID := fs.String("tenant", envOr("INIT_TENANT_ID", "default"), "tenant id for admin and role permissions")
|
tenantID := fs.String("tenant", envOr("INIT_TENANT_ID", "default"), "tenant id for admin and role permissions")
|
||||||
email := fs.String("email", envOr("INIT_ADMIN_EMAIL", "admin@haixun.local"), "bootstrap admin email")
|
email := fs.String("email", envOr("INIT_ADMIN_EMAIL", "admin@30cm.net"), "bootstrap admin email")
|
||||||
password := fs.String("password", envOr("INIT_ADMIN_PASSWORD", "Admin-Pass-1!"), "bootstrap admin password")
|
password := fs.String("password", envOr("INIT_ADMIN_PASSWORD", "Fafafa54088"), "bootstrap admin password")
|
||||||
displayName := fs.String("display-name", envOr("INIT_ADMIN_DISPLAY_NAME", "Admin"), "bootstrap admin display name")
|
displayName := fs.String("display-name", envOr("INIT_ADMIN_DISPLAY_NAME", "Admin"), "bootstrap admin display name")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var cfg config.Config
|
var cfg config.Config
|
||||||
conf.MustLoad(*configFile, &cfg)
|
conf.MustLoad(*configFile, &cfg, conf.UseEnv())
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
var c config.Config
|
var c config.Config
|
||||||
conf.MustLoad(*configFile, &c)
|
conf.MustLoad(*configFile, &c, conf.UseEnv())
|
||||||
if !c.JobWorker.Enabled {
|
if !c.JobWorker.Enabled {
|
||||||
fmt.Fprintln(os.Stderr, "[worker] JobWorker.Enabled must be true")
|
fmt.Fprintln(os.Stderr, "[worker] JobWorker.Enabled must be true")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,30 @@ Host: 0.0.0.0
|
||||||
Port: 8890
|
Port: 8890
|
||||||
Timeout: 120000
|
Timeout: 120000
|
||||||
|
|
||||||
|
# 連線字串與所有 secret 都從環境變數注入(systemd EnvironmentFile=/opt/haixun/etc/haixun.env)。
|
||||||
|
# go-zero 以 conf.UseEnv() + os.ExpandEnv 展開 ${VAR};未設定的變數會展開為空字串並讓服務 fail fast。
|
||||||
Mongo:
|
Mongo:
|
||||||
URI: mongodb://mongo:27017
|
URI: ${HAIXUN_MONGO_URI}
|
||||||
Database: haixun
|
Database: ${HAIXUN_MONGO_DB}
|
||||||
TimeoutSeconds: 10
|
TimeoutSeconds: 10
|
||||||
|
|
||||||
Redis:
|
Redis:
|
||||||
Addr: redis:6379
|
Addr: ${HAIXUN_REDIS_ADDR}
|
||||||
|
Password: ${HAIXUN_REDIS_PASSWORD}
|
||||||
DB: 0
|
DB: 0
|
||||||
|
|
||||||
Auth:
|
Auth:
|
||||||
AccessSecret: change-me-in-prod
|
AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET}
|
||||||
RefreshSecret: change-me-in-prod-too
|
RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET}
|
||||||
AccessExpireSeconds: 900
|
AccessExpireSeconds: 900
|
||||||
RefreshExpireSeconds: 2592000
|
RefreshExpireSeconds: 2592000
|
||||||
DevHeaderFallback: false
|
DevHeaderFallback: false
|
||||||
|
|
||||||
|
Secrets:
|
||||||
|
EncryptionKey: ${HAIXUN_SECRETS_KEY}
|
||||||
|
|
||||||
InternalWorker:
|
InternalWorker:
|
||||||
Secret: change-me-worker-secret
|
Secret: ${HAIXUN_WORKER_SECRET}
|
||||||
|
|
||||||
JobWorker:
|
JobWorker:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
Name: haixun-worker
|
||||||
|
Host: 0.0.0.0
|
||||||
|
Port: 8891
|
||||||
|
Timeout: 120000
|
||||||
|
|
||||||
|
Mongo:
|
||||||
|
URI: ${HAIXUN_MONGO_URI}
|
||||||
|
Database: ${HAIXUN_MONGO_DB}
|
||||||
|
TimeoutSeconds: 10
|
||||||
|
|
||||||
|
Redis:
|
||||||
|
Addr: ${HAIXUN_REDIS_ADDR}
|
||||||
|
Password: ${HAIXUN_REDIS_PASSWORD}
|
||||||
|
DB: 0
|
||||||
|
|
||||||
|
Auth:
|
||||||
|
AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET}
|
||||||
|
RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET}
|
||||||
|
AccessExpireSeconds: 900
|
||||||
|
RefreshExpireSeconds: 2592000
|
||||||
|
DevHeaderFallback: false
|
||||||
|
|
||||||
|
Secrets:
|
||||||
|
EncryptionKey: ${HAIXUN_SECRETS_KEY}
|
||||||
|
|
||||||
|
InternalWorker:
|
||||||
|
Secret: ${HAIXUN_WORKER_SECRET}
|
||||||
|
|
||||||
|
JobWorker:
|
||||||
|
Enabled: true
|
||||||
|
WorkerType: go
|
||||||
|
|
||||||
|
JobScheduler:
|
||||||
|
Enabled: false
|
||||||
|
IntervalSeconds: 60
|
||||||
|
|
||||||
|
JobReaper:
|
||||||
|
Enabled: false
|
||||||
|
IntervalSeconds: 30
|
||||||
|
|
@ -3,24 +3,29 @@ Host: 0.0.0.0
|
||||||
Port: 8891
|
Port: 8891
|
||||||
Timeout: 120000
|
Timeout: 120000
|
||||||
|
|
||||||
|
# 本機開發 worker 設定(go worker)。搭配 `make dev-infra` 的 Mongo/Redis。
|
||||||
Mongo:
|
Mongo:
|
||||||
URI: mongodb://mongo:27017
|
URI: mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin
|
||||||
Database: haixun
|
Database: haixun
|
||||||
TimeoutSeconds: 10
|
TimeoutSeconds: 10
|
||||||
|
|
||||||
Redis:
|
Redis:
|
||||||
Addr: redis:6379
|
Addr: 127.0.0.1:6379
|
||||||
|
Password: change-me-redis-pass
|
||||||
DB: 0
|
DB: 0
|
||||||
|
|
||||||
Auth:
|
Auth:
|
||||||
AccessSecret: change-me-in-prod
|
AccessSecret: haixun-dev-access-secret-change-me
|
||||||
RefreshSecret: change-me-in-prod-too
|
RefreshSecret: haixun-dev-refresh-secret-change-me
|
||||||
AccessExpireSeconds: 900
|
AccessExpireSeconds: 900
|
||||||
RefreshExpireSeconds: 2592000
|
RefreshExpireSeconds: 2592000
|
||||||
DevHeaderFallback: false
|
DevHeaderFallback: false
|
||||||
|
|
||||||
|
Secrets:
|
||||||
|
EncryptionKey: ""
|
||||||
|
|
||||||
InternalWorker:
|
InternalWorker:
|
||||||
Secret: change-me-worker-secret
|
Secret: haixun-dev-worker-secret
|
||||||
|
|
||||||
JobWorker:
|
JobWorker:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,16 @@ Host: 0.0.0.0
|
||||||
Port: 8890
|
Port: 8890
|
||||||
Timeout: 120000
|
Timeout: 120000
|
||||||
|
|
||||||
|
# 本機開發設定。預設搭配 `make dev-infra`(infra/docker-compose.yml)跑的 Mongo/Redis,
|
||||||
|
# 帳密對應 infra/.env.example 的預設值。若你改了 .env 密碼,這裡也要同步。
|
||||||
Mongo:
|
Mongo:
|
||||||
URI: mongodb://127.0.0.1:27017
|
URI: mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin
|
||||||
Database: haixun
|
Database: haixun
|
||||||
TimeoutSeconds: 10
|
TimeoutSeconds: 10
|
||||||
|
|
||||||
Redis:
|
Redis:
|
||||||
Addr: 127.0.0.1:6379
|
Addr: 127.0.0.1:6379
|
||||||
|
Password: change-me-redis-pass
|
||||||
DB: 0
|
DB: 0
|
||||||
|
|
||||||
Auth:
|
Auth:
|
||||||
|
|
@ -17,8 +20,16 @@ Auth:
|
||||||
RefreshSecret: haixun-dev-refresh-secret-change-me
|
RefreshSecret: haixun-dev-refresh-secret-change-me
|
||||||
AccessExpireSeconds: 900
|
AccessExpireSeconds: 900
|
||||||
RefreshExpireSeconds: 2592000
|
RefreshExpireSeconds: 2592000
|
||||||
|
# 僅本機開發開啟:允許用 X-Tenant-ID / X-UID header 模擬登入。正式環境必須為 false。
|
||||||
DevHeaderFallback: true
|
DevHeaderFallback: true
|
||||||
|
|
||||||
|
Secrets:
|
||||||
|
# 留空 = 不加密(本機開發方便)。正式環境用 ${HAIXUN_SECRETS_KEY}。
|
||||||
|
EncryptionKey: ""
|
||||||
|
|
||||||
|
InternalWorker:
|
||||||
|
Secret: haixun-dev-worker-secret
|
||||||
|
|
||||||
JobWorker:
|
JobWorker:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
WorkerType: go
|
WorkerType: go
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
var c config.Config
|
var c config.Config
|
||||||
conf.MustLoad(*configFile, &c)
|
conf.MustLoad(*configFile, &c, conf.UseEnv())
|
||||||
|
|
||||||
server := rest.MustNewServer(c.RestConf)
|
server := rest.MustNewServer(c.RestConf)
|
||||||
defer server.Stop()
|
defer server.Stop()
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,23 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"haixun-backend/internal/config"
|
"haixun-backend/internal/config"
|
||||||
|
libcrypto "haixun-backend/internal/library/crypto"
|
||||||
libmongo "haixun-backend/internal/library/mongo"
|
libmongo "haixun-backend/internal/library/mongo"
|
||||||
|
brandrepo "haixun-backend/internal/model/brand/repository"
|
||||||
|
cmatrixrepo "haixun-backend/internal/model/content_matrix/repository"
|
||||||
|
copydraftrepo "haixun-backend/internal/model/copy_draft/repository"
|
||||||
|
copymissionrepo "haixun-backend/internal/model/copy_mission/repository"
|
||||||
jobrepo "haixun-backend/internal/model/job/repository"
|
jobrepo "haixun-backend/internal/model/job/repository"
|
||||||
|
kgrepo "haixun-backend/internal/model/knowledge_graph/repository"
|
||||||
memberrepo "haixun-backend/internal/model/member/repository"
|
memberrepo "haixun-backend/internal/model/member/repository"
|
||||||
|
outreachdraftrepo "haixun-backend/internal/model/outreach_draft/repository"
|
||||||
permissionrepo "haixun-backend/internal/model/permission/repository"
|
permissionrepo "haixun-backend/internal/model/permission/repository"
|
||||||
permissionuc "haixun-backend/internal/model/permission/usecase"
|
permissionuc "haixun-backend/internal/model/permission/usecase"
|
||||||
|
personarepo "haixun-backend/internal/model/persona/repository"
|
||||||
|
placementtopicrepo "haixun-backend/internal/model/placement_topic/repository"
|
||||||
|
scanpostrepo "haixun-backend/internal/model/scan_post/repository"
|
||||||
settingrepo "haixun-backend/internal/model/setting/repository"
|
settingrepo "haixun-backend/internal/model/setting/repository"
|
||||||
|
threadsaccountrepo "haixun-backend/internal/model/threads_account/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
type InitOptions struct {
|
type InitOptions struct {
|
||||||
|
|
@ -48,6 +59,12 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport
|
||||||
db := mongoClient.Database()
|
db := mongoClient.Database()
|
||||||
report := &InitReport{}
|
report := &InitReport{}
|
||||||
|
|
||||||
|
// cipher 只用於資料加解密;EnsureIndexes 不會用到,但 secrets repo 建構子需要它。
|
||||||
|
secretsCipher, err := libcrypto.New(cfg.Secrets.EncryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("init secrets cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
settingRepository := settingrepo.NewMongoRepository(db)
|
settingRepository := settingrepo.NewMongoRepository(db)
|
||||||
memberRepository := memberrepo.NewMongoRepository(db)
|
memberRepository := memberrepo.NewMongoRepository(db)
|
||||||
permissionRepository := permissionrepo.NewMongoPermissionRepository(db)
|
permissionRepository := permissionrepo.NewMongoPermissionRepository(db)
|
||||||
|
|
@ -69,6 +86,17 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport
|
||||||
{"job_runs", jobRunRepository.EnsureIndexes},
|
{"job_runs", jobRunRepository.EnsureIndexes},
|
||||||
{"job_schedules", jobScheduleRepository.EnsureIndexes},
|
{"job_schedules", jobScheduleRepository.EnsureIndexes},
|
||||||
{"job_events", jobEventRepository.EnsureIndexes},
|
{"job_events", jobEventRepository.EnsureIndexes},
|
||||||
|
{"copy_missions", copymissionrepo.NewMongoRepository(db).EnsureIndexes},
|
||||||
|
{"copy_drafts", copydraftrepo.NewMongoRepository(db).EnsureIndexes},
|
||||||
|
{"scan_posts", scanpostrepo.NewMongoRepository(db).EnsureIndexes},
|
||||||
|
{"outreach_drafts", outreachdraftrepo.NewMongoRepository(db).EnsureIndexes},
|
||||||
|
{"content_matrix", cmatrixrepo.NewMongoRepository(db).EnsureIndexes},
|
||||||
|
{"knowledge_graph", kgrepo.NewMongoRepository(db).EnsureIndexes},
|
||||||
|
{"personas", personarepo.NewMongoRepository(db).EnsureIndexes},
|
||||||
|
{"brands", brandrepo.NewMongoRepository(db).EnsureIndexes},
|
||||||
|
{"placement_topics", placementtopicrepo.NewMongoRepository(db).EnsureIndexes},
|
||||||
|
{"threads_accounts", threadsaccountrepo.NewMongoRepository(db).EnsureIndexes},
|
||||||
|
{"threads_account_secrets", threadsaccountrepo.NewSecretsMongoRepository(db, secretsCipher).EnsureIndexes},
|
||||||
}
|
}
|
||||||
for _, repo := range repos {
|
for _, repo := range repos {
|
||||||
if err := repo.fn(ctx); err != nil {
|
if err := repo.fn(ctx); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ type MongoConf struct {
|
||||||
|
|
||||||
type RedisConf struct {
|
type RedisConf struct {
|
||||||
Addr string `json:",optional"`
|
Addr string `json:",optional"`
|
||||||
|
Password string `json:",optional"`
|
||||||
DB int `json:",optional"`
|
DB int `json:",optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,13 +35,19 @@ type AuthConf struct {
|
||||||
RefreshSecret string `json:",optional"`
|
RefreshSecret string `json:",optional"`
|
||||||
AccessExpireSeconds int64 `json:",default=900"`
|
AccessExpireSeconds int64 `json:",default=900"`
|
||||||
RefreshExpireSeconds int64 `json:",default=2592000"`
|
RefreshExpireSeconds int64 `json:",default=2592000"`
|
||||||
DevHeaderFallback bool `json:",default=true"`
|
DevHeaderFallback bool `json:",default=false"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type InternalWorkerConf struct {
|
type InternalWorkerConf struct {
|
||||||
Secret string `json:",optional"`
|
Secret string `json:",optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SecretsConf holds the application-layer encryption key (base64 of 32 bytes)
|
||||||
|
// used to encrypt sensitive data at rest (browser session, third-party API keys).
|
||||||
|
type SecretsConf struct {
|
||||||
|
EncryptionKey string `json:",optional"`
|
||||||
|
}
|
||||||
|
|
||||||
type BraveConf struct {
|
type BraveConf struct {
|
||||||
APIKey string `json:",optional"`
|
APIKey string `json:",optional"`
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +57,7 @@ type Config struct {
|
||||||
Mongo MongoConf `json:",optional"`
|
Mongo MongoConf `json:",optional"`
|
||||||
Redis RedisConf `json:",optional"`
|
Redis RedisConf `json:",optional"`
|
||||||
Auth AuthConf `json:",optional"`
|
Auth AuthConf `json:",optional"`
|
||||||
|
Secrets SecretsConf `json:",optional"`
|
||||||
InternalWorker InternalWorkerConf `json:",optional"`
|
InternalWorker InternalWorkerConf `json:",optional"`
|
||||||
JobWorker JobWorkerConf `json:",optional"`
|
JobWorker JobWorkerConf `json:",optional"`
|
||||||
JobScheduler JobSchedulerConf `json:",optional"`
|
JobScheduler JobSchedulerConf `json:",optional"`
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
// Package crypto provides application-layer encryption for sensitive data at
|
||||||
|
// rest (browser session storage, third-party API keys).
|
||||||
|
//
|
||||||
|
// Ciphertext format: "enc:v1:" + base64url(nonce || ciphertext||tag).
|
||||||
|
// Values without the prefix are treated as legacy plaintext and returned as-is
|
||||||
|
// on Decrypt, so enabling encryption is backward compatible with existing data.
|
||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const cipherPrefix = "enc:v1:"
|
||||||
|
|
||||||
|
// Cipher encrypts/decrypts secret strings. When built without a key it is
|
||||||
|
// disabled and acts as a passthrough (for local dev), but Decrypt still
|
||||||
|
// transparently handles previously written ciphertext if any.
|
||||||
|
type Cipher struct {
|
||||||
|
aead cipher.AEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a Cipher from a base64-encoded 32-byte (AES-256) key.
|
||||||
|
// An empty key returns a disabled (passthrough) cipher.
|
||||||
|
func New(base64Key string) (*Cipher, error) {
|
||||||
|
base64Key = strings.TrimSpace(base64Key)
|
||||||
|
if base64Key == "" {
|
||||||
|
return &Cipher{}, nil
|
||||||
|
}
|
||||||
|
key, err := base64.StdEncoding.DecodeString(base64Key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("crypto: invalid base64 encryption key: %w", err)
|
||||||
|
}
|
||||||
|
if len(key) != 32 {
|
||||||
|
return nil, fmt.Errorf("crypto: encryption key must be 32 bytes (got %d)", len(key))
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("crypto: new cipher: %w", err)
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("crypto: new gcm: %w", err)
|
||||||
|
}
|
||||||
|
return &Cipher{aead: aead}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether a key is configured.
|
||||||
|
func (c *Cipher) Enabled() bool {
|
||||||
|
return c != nil && c.aead != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt returns ciphertext for non-empty plaintext when enabled; otherwise
|
||||||
|
// it returns the input unchanged.
|
||||||
|
func (c *Cipher) Encrypt(plaintext string) (string, error) {
|
||||||
|
if !c.Enabled() || plaintext == "" {
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(plaintext, cipherPrefix) {
|
||||||
|
// Already encrypted; avoid double-encrypting.
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
nonce := make([]byte, c.aead.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return "", fmt.Errorf("crypto: read nonce: %w", err)
|
||||||
|
}
|
||||||
|
sealed := c.aead.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||||
|
return cipherPrefix + base64.RawURLEncoding.EncodeToString(sealed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt reverses Encrypt. Values without the cipher prefix are returned
|
||||||
|
// unchanged (legacy plaintext), keeping the change backward compatible.
|
||||||
|
func (c *Cipher) Decrypt(value string) (string, error) {
|
||||||
|
if !strings.HasPrefix(value, cipherPrefix) {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
if !c.Enabled() {
|
||||||
|
return "", errors.New("crypto: encrypted value found but no encryption key configured")
|
||||||
|
}
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(value, cipherPrefix))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("crypto: decode ciphertext: %w", err)
|
||||||
|
}
|
||||||
|
nonceSize := c.aead.NonceSize()
|
||||||
|
if len(raw) < nonceSize {
|
||||||
|
return "", errors.New("crypto: ciphertext too short")
|
||||||
|
}
|
||||||
|
nonce, ct := raw[:nonceSize], raw[nonceSize:]
|
||||||
|
plain, err := c.aead.Open(nil, nonce, ct, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("crypto: decrypt: %w", err)
|
||||||
|
}
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ func NewClient(conf config.RedisConf) *goredis.Client {
|
||||||
}
|
}
|
||||||
return goredis.NewClient(&goredis.Options{
|
return goredis.NewClient(&goredis.Options{
|
||||||
Addr: conf.Addr,
|
Addr: conf.Addr,
|
||||||
|
Password: conf.Password,
|
||||||
DB: conf.DB,
|
DB: conf.DB,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"haixun-backend/internal/library/authctx"
|
||||||
|
app "haixun-backend/internal/library/errors"
|
||||||
|
"haixun-backend/internal/library/errors/code"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// authorizeSettingAccess guards the generic settings API against horizontal
|
||||||
|
// privilege escalation (IDOR). The settings collection stores sensitive values
|
||||||
|
// such as AI / Brave / Exa API keys under the "user" and "account" scopes, so
|
||||||
|
// the caller must be the owner of the scope being accessed.
|
||||||
|
//
|
||||||
|
// - "user": scope_id must equal the caller uid (uid is enumerable).
|
||||||
|
// - "account": the threads account must belong to the caller.
|
||||||
|
// - other scopes (brand / persona / placement_topic / copy_mission ...):
|
||||||
|
// keyed by unguessable ObjectIDs and hold non-secret research config;
|
||||||
|
// ownership is enforced by their own feature endpoints. We still require an
|
||||||
|
// authenticated actor here.
|
||||||
|
func authorizeSettingAccess(ctx context.Context, svcCtx *svc.ServiceContext, scope, scopeID string) error {
|
||||||
|
actor, ok := authctx.ActorFromContext(ctx)
|
||||||
|
if !ok || actor.UID == "" {
|
||||||
|
return app.For(code.Auth).AuthUnauthorized("missing actor")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch scope {
|
||||||
|
case "user":
|
||||||
|
if scopeID != actor.UID {
|
||||||
|
return app.For(code.Setting).AuthForbidden("cannot access another user's settings")
|
||||||
|
}
|
||||||
|
case "account":
|
||||||
|
if _, err := svcCtx.ThreadsAccount.Get(ctx, actor.TenantID, actor.UID, scopeID); err != nil {
|
||||||
|
return app.For(code.Setting).AuthForbidden("cannot access settings of an account you do not own")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -17,5 +17,8 @@ func NewDeleteSettingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Del
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *DeleteSettingLogic) DeleteSetting(req *types.SettingKeyPath) error {
|
func (l *DeleteSettingLogic) DeleteSetting(req *types.SettingKeyPath) error {
|
||||||
|
if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return l.svcCtx.Setting.Delete(l.ctx, req.Scope, req.ScopeID, req.Key)
|
return l.svcCtx.Setting.Delete(l.ctx, req.Scope, req.ScopeID, req.Key)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ func NewGetSettingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetSet
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *GetSettingLogic) GetSetting(req *types.SettingKeyPath) (*types.SettingData, error) {
|
func (l *GetSettingLogic) GetSetting(req *types.SettingKeyPath) (*types.SettingData, error) {
|
||||||
|
if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
item, err := l.svcCtx.Setting.Get(l.ctx, req.Scope, req.ScopeID, req.Key)
|
item, err := l.svcCtx.Setting.Get(l.ctx, req.Scope, req.ScopeID, req.Key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ func NewListSettingsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *List
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ListSettingsLogic) ListSettings(req *types.SettingPath) (*types.SettingListData, error) {
|
func (l *ListSettingsLogic) ListSettings(req *types.SettingPath) (*types.SettingListData, error) {
|
||||||
|
if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
items, total, page, pageSize, err := l.svcCtx.Setting.List(l.ctx, req.Scope, req.ScopeID, req.Page, req.PageSize)
|
items, total, page, pageSize, err := l.svcCtx.Setting.List(l.ctx, req.Scope, req.ScopeID, req.Page, req.PageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ func NewUpsertSettingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Ups
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *UpsertSettingLogic) UpsertSetting(req *types.SettingUpsertReq) (*types.SettingData, error) {
|
func (l *UpsertSettingLogic) UpsertSetting(req *types.SettingUpsertReq) (*types.SettingData, error) {
|
||||||
|
if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
item, err := l.svcCtx.Setting.Upsert(l.ctx, settinguc.UpsertRequest{
|
item, err := l.svcCtx.Setting.Upsert(l.ctx, settinguc.UpsertRequest{
|
||||||
Scope: req.Scope,
|
Scope: req.Scope,
|
||||||
ScopeID: req.ScopeID,
|
ScopeID: req.ScopeID,
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package middleware
|
|
||||||
|
|
||||||
import "net/http"
|
|
||||||
|
|
||||||
type AuthMiddleware struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAuthMiddleware() *AuthMiddleware {
|
|
||||||
return &AuthMiddleware{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *AuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// TODO generate middleware implement function, delete after code implementation
|
|
||||||
|
|
||||||
// Passthrough to next handler if need
|
|
||||||
next(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -13,7 +13,14 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// PermissionRBACMiddleware enforces catalog permissions for authenticated members.
|
// PermissionRBACMiddleware enforces catalog permissions for authenticated members.
|
||||||
// Mount after AuthJWT or MemberAuth so actor is present in context.
|
// It is composed AFTER AuthJWT / MemberAuth (see svc.NewServiceContext) so the
|
||||||
|
// actor is already in context.
|
||||||
|
//
|
||||||
|
// Route coverage: defaultPermissions uses trailing-* patterns
|
||||||
|
// (/api/v1/brands/*, /api/v1/personas/*, /api/v1/placement/topics/*, ...) that
|
||||||
|
// cover nested routes (copy-missions, scan-posts, knowledge-graph, etc.).
|
||||||
|
// permission.Me also falls back to the default user permission set when a tenant
|
||||||
|
// has no seeded role_permissions, so members are never locked out by a missing seed.
|
||||||
type PermissionRBACMiddleware struct {
|
type PermissionRBACMiddleware struct {
|
||||||
members memberdomain.UseCase
|
members memberdomain.UseCase
|
||||||
permissions permissiondomain.UseCase
|
permissions permissiondomain.UseCase
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
|
@ -12,10 +13,10 @@ import (
|
||||||
|
|
||||||
const WorkerSecretHeader = "X-Worker-Secret"
|
const WorkerSecretHeader = "X-Worker-Secret"
|
||||||
|
|
||||||
// WorkerSecretMiddleware enforces X-Worker-Secret on internal worker routes when
|
// WorkerSecretMiddleware enforces X-Worker-Secret on internal worker routes.
|
||||||
// InternalWorker.Secret is configured. Mounted via @server(middleware: WorkerSecret)
|
// The secret is REQUIRED: when InternalWorker.Secret is empty the middleware
|
||||||
// in generate/api/worker_internal.api. When the secret is empty it passes through,
|
// rejects every request (fail closed) instead of passing through, so the
|
||||||
// preserving local-dev behaviour.
|
// internal worker endpoints can never be exposed unauthenticated.
|
||||||
type WorkerSecretMiddleware struct {
|
type WorkerSecretMiddleware struct {
|
||||||
cfg config.InternalWorkerConf
|
cfg config.InternalWorkerConf
|
||||||
}
|
}
|
||||||
|
|
@ -27,7 +28,12 @@ func NewWorkerSecretMiddleware(cfg config.InternalWorkerConf) *WorkerSecretMiddl
|
||||||
func (m *WorkerSecretMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
|
func (m *WorkerSecretMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
secret := strings.TrimSpace(m.cfg.Secret)
|
secret := strings.TrimSpace(m.cfg.Secret)
|
||||||
if secret != "" && r.Header.Get(WorkerSecretHeader) != secret {
|
if secret == "" {
|
||||||
|
response.Write(r.Context(), w, nil, app.For(code.Auth).AuthForbidden("internal worker secret is not configured"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
provided := r.Header.Get(WorkerSecretHeader)
|
||||||
|
if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 {
|
||||||
response.Write(r.Context(), w, nil, app.For(code.Auth).AuthUnauthorized("invalid worker secret"))
|
response.Write(r.Context(), w, nil, app.For(code.Auth).AuthUnauthorized("invalid worker secret"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@ type tokenUseCase struct {
|
||||||
|
|
||||||
func NewTokenUseCase(cfg config.AuthConf, revoke domrepo.TokenRevokeStore) domusecase.TokenUseCase {
|
func NewTokenUseCase(cfg config.AuthConf, revoke domrepo.TokenRevokeStore) domusecase.TokenUseCase {
|
||||||
cfg = normalizeConfig(cfg)
|
cfg = normalizeConfig(cfg)
|
||||||
|
if cfg.AccessSecret == "" || cfg.RefreshSecret == "" {
|
||||||
|
// Fail fast: never fall back to a known/hardcoded secret, otherwise tokens
|
||||||
|
// could be forged. Provide secrets via env (HAIXUN_JWT_ACCESS_SECRET /
|
||||||
|
// HAIXUN_JWT_REFRESH_SECRET) or the dev config file.
|
||||||
|
panic("auth: AccessSecret and RefreshSecret must be configured")
|
||||||
|
}
|
||||||
return &tokenUseCase{cfg: cfg, revoke: revoke}
|
return &tokenUseCase{cfg: cfg, revoke: revoke}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,12 +216,6 @@ func normalizeConfig(cfg config.AuthConf) config.AuthConf {
|
||||||
if cfg.RefreshExpireSeconds <= 0 {
|
if cfg.RefreshExpireSeconds <= 0 {
|
||||||
cfg.RefreshExpireSeconds = 2592000
|
cfg.RefreshExpireSeconds = 2592000
|
||||||
}
|
}
|
||||||
if cfg.AccessSecret == "" {
|
|
||||||
cfg.AccessSecret = "haixun-dev-access-secret-change-me"
|
|
||||||
}
|
|
||||||
if cfg.RefreshSecret == "" {
|
|
||||||
cfg.RefreshSecret = "haixun-dev-refresh-secret-change-me"
|
|
||||||
}
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"haixun-backend/internal/library/crypto"
|
||||||
app "haixun-backend/internal/library/errors"
|
app "haixun-backend/internal/library/errors"
|
||||||
"haixun-backend/internal/library/errors/code"
|
"haixun-backend/internal/library/errors/code"
|
||||||
libkg "haixun-backend/internal/library/knowledge"
|
libkg "haixun-backend/internal/library/knowledge"
|
||||||
|
|
@ -47,10 +48,11 @@ type UseCase interface {
|
||||||
|
|
||||||
type placementUseCase struct {
|
type placementUseCase struct {
|
||||||
settings settingdomain.UseCase
|
settings settingdomain.UseCase
|
||||||
|
cipher *crypto.Cipher
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUseCase(settings settingdomain.UseCase) UseCase {
|
func NewUseCase(settings settingdomain.UseCase, cipher *crypto.Cipher) UseCase {
|
||||||
return &placementUseCase{settings: settings}
|
return &placementUseCase{settings: settings, cipher: cipher}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *placementUseCase) Get(ctx context.Context, tenantID, ownerUID string) (*Settings, error) {
|
func (u *placementUseCase) Get(ctx context.Context, tenantID, ownerUID string) (*Settings, error) {
|
||||||
|
|
@ -107,15 +109,49 @@ func (u *placementUseCase) load(ctx context.Context, ownerUID string) (storedSet
|
||||||
}
|
}
|
||||||
return defaults, err
|
return defaults, err
|
||||||
}
|
}
|
||||||
return mergeSettings(defaults, setting.Value), nil
|
merged := mergeSettings(defaults, setting.Value)
|
||||||
|
if u.cipher != nil {
|
||||||
|
if merged.BraveAPIKey, err = u.decryptKey(merged.BraveAPIKey); err != nil {
|
||||||
|
return defaults, err
|
||||||
|
}
|
||||||
|
if merged.ExaAPIKey, err = u.decryptKey(merged.ExaAPIKey); err != nil {
|
||||||
|
return defaults, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *placementUseCase) decryptKey(value string) (string, error) {
|
||||||
|
if value == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
plain, err := u.cipher.Decrypt(value)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(plain), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *placementUseCase) save(ctx context.Context, ownerUID string, value storedSettings) error {
|
func (u *placementUseCase) save(ctx context.Context, ownerUID string, value storedSettings) error {
|
||||||
|
payload := value.toMap()
|
||||||
|
if u.cipher != nil {
|
||||||
|
for _, key := range []string{"brave_api_key", "exa_api_key"} {
|
||||||
|
s, ok := payload[key].(string)
|
||||||
|
if !ok || s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
enc, err := u.cipher.Encrypt(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload[key] = enc
|
||||||
|
}
|
||||||
|
}
|
||||||
_, err := u.settings.Upsert(ctx, settingdomain.UpsertRequest{
|
_, err := u.settings.Upsert(ctx, settingdomain.UpsertRequest{
|
||||||
Scope: settingScopeUser,
|
Scope: settingScopeUser,
|
||||||
ScopeID: ownerUID,
|
ScopeID: ownerUID,
|
||||||
Key: keyResearch,
|
Key: keyResearch,
|
||||||
Value: value.toMap(),
|
Value: payload,
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"haixun-backend/internal/library/clock"
|
"haixun-backend/internal/library/clock"
|
||||||
|
"haixun-backend/internal/library/crypto"
|
||||||
app "haixun-backend/internal/library/errors"
|
app "haixun-backend/internal/library/errors"
|
||||||
"haixun-backend/internal/library/errors/code"
|
"haixun-backend/internal/library/errors/code"
|
||||||
"haixun-backend/internal/model/threads_account/domain/entity"
|
"haixun-backend/internal/model/threads_account/domain/entity"
|
||||||
|
|
@ -16,13 +17,14 @@ import (
|
||||||
|
|
||||||
type secretsMongoRepository struct {
|
type secretsMongoRepository struct {
|
||||||
collection *mongo.Collection
|
collection *mongo.Collection
|
||||||
|
cipher *crypto.Cipher
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSecretsMongoRepository(db *mongo.Database) domrepo.SecretsRepository {
|
func NewSecretsMongoRepository(db *mongo.Database, cipher *crypto.Cipher) domrepo.SecretsRepository {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return &secretsMongoRepository{}
|
return &secretsMongoRepository{cipher: cipher}
|
||||||
}
|
}
|
||||||
return &secretsMongoRepository{collection: db.Collection(entity.SecretsCollectionName)}
|
return &secretsMongoRepository{collection: db.Collection(entity.SecretsCollectionName), cipher: cipher}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *secretsMongoRepository) EnsureIndexes(ctx context.Context) error {
|
func (r *secretsMongoRepository) EnsureIndexes(ctx context.Context) error {
|
||||||
|
|
@ -47,6 +49,13 @@ func (r *secretsMongoRepository) FindByAccountID(ctx context.Context, accountID
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if r.cipher != nil && out.BrowserStorageState != "" {
|
||||||
|
plain, decErr := r.cipher.Decrypt(out.BrowserStorageState)
|
||||||
|
if decErr != nil {
|
||||||
|
return nil, decErr
|
||||||
|
}
|
||||||
|
out.BrowserStorageState = plain
|
||||||
|
}
|
||||||
return &out, nil
|
return &out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,13 +64,21 @@ func (r *secretsMongoRepository) SaveBrowserStorageState(ctx context.Context, ac
|
||||||
return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
|
return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
|
||||||
}
|
}
|
||||||
now := clock.NowUnixNano()
|
now := clock.NowUnixNano()
|
||||||
|
storedValue := storageState
|
||||||
|
if r.cipher != nil {
|
||||||
|
enc, encErr := r.cipher.Encrypt(storageState)
|
||||||
|
if encErr != nil {
|
||||||
|
return nil, encErr
|
||||||
|
}
|
||||||
|
storedValue = enc
|
||||||
|
}
|
||||||
var out entity.Secrets
|
var out entity.Secrets
|
||||||
err := r.collection.FindOneAndUpdate(
|
err := r.collection.FindOneAndUpdate(
|
||||||
ctx,
|
ctx,
|
||||||
bson.M{"_id": accountID},
|
bson.M{"_id": accountID},
|
||||||
bson.M{
|
bson.M{
|
||||||
"$set": bson.M{
|
"$set": bson.M{
|
||||||
"browser_storage_state": storageState,
|
"browser_storage_state": storedValue,
|
||||||
"update_at": now,
|
"update_at": now,
|
||||||
},
|
},
|
||||||
"$setOnInsert": bson.M{"_id": accountID},
|
"$setOnInsert": bson.M{"_id": accountID},
|
||||||
|
|
@ -71,5 +88,7 @@ func (r *secretsMongoRepository) SaveBrowserStorageState(ctx context.Context, ac
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Return plaintext to callers regardless of at-rest encryption.
|
||||||
|
out.BrowserStorageState = storageState
|
||||||
return &out, nil
|
return &out, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ func (u *threadsAccountUseCase) loadAiCredentials(ctx context.Context, ownerUID,
|
||||||
defaults := defaultAiCredentials()
|
defaults := defaultAiCredentials()
|
||||||
setting, err := u.settings.Get(ctx, settingScopeUser, ownerUID, keyAiCredentials)
|
setting, err := u.settings.Get(ctx, settingScopeUser, ownerUID, keyAiCredentials)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return mergeAiCredentials(defaults, setting.Value), nil
|
return u.mergeAiCredentials(defaults, setting.Value)
|
||||||
}
|
}
|
||||||
if !isSettingNotFound(err) {
|
if !isSettingNotFound(err) {
|
||||||
return defaults, err
|
return defaults, err
|
||||||
|
|
@ -142,7 +142,10 @@ func (u *threadsAccountUseCase) loadAiCredentials(ctx context.Context, ownerUID,
|
||||||
}
|
}
|
||||||
return defaults, err
|
return defaults, err
|
||||||
}
|
}
|
||||||
creds := mergeAiCredentials(defaults, legacy.Value)
|
creds, err := u.mergeAiCredentials(defaults, legacy.Value)
|
||||||
|
if err != nil {
|
||||||
|
return defaults, err
|
||||||
|
}
|
||||||
if saveErr := u.saveAiCredentials(ctx, ownerUID, creds); saveErr != nil {
|
if saveErr := u.saveAiCredentials(ctx, ownerUID, creds); saveErr != nil {
|
||||||
return creds, saveErr
|
return creds, saveErr
|
||||||
}
|
}
|
||||||
|
|
@ -150,18 +153,24 @@ func (u *threadsAccountUseCase) loadAiCredentials(ctx context.Context, ownerUID,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *threadsAccountUseCase) saveAiCredentials(ctx context.Context, ownerUID string, creds aiCredentials) error {
|
func (u *threadsAccountUseCase) saveAiCredentials(ctx context.Context, ownerUID string, creds aiCredentials) error {
|
||||||
_, err := u.settings.Upsert(ctx, settingdomain.UpsertRequest{
|
value, err := u.aiCredentialsToMap(creds)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = u.settings.Upsert(ctx, settingdomain.UpsertRequest{
|
||||||
Scope: settingScopeUser,
|
Scope: settingScopeUser,
|
||||||
ScopeID: ownerUID,
|
ScopeID: ownerUID,
|
||||||
Key: keyAiCredentials,
|
Key: keyAiCredentials,
|
||||||
Value: aiCredentialsToMap(creds),
|
Value: value,
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeAiCredentials(defaults aiCredentials, value map[string]interface{}) aiCredentials {
|
// mergeAiCredentials merges stored setting values onto defaults, decrypting any
|
||||||
|
// at-rest encrypted API keys.
|
||||||
|
func (u *threadsAccountUseCase) mergeAiCredentials(defaults aiCredentials, value map[string]interface{}) (aiCredentials, error) {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return defaults
|
return defaults, nil
|
||||||
}
|
}
|
||||||
if v, ok := value["provider"].(string); ok && strings.TrimSpace(v) != "" {
|
if v, ok := value["provider"].(string); ok && strings.TrimSpace(v) != "" {
|
||||||
defaults.Provider = v
|
defaults.Provider = v
|
||||||
|
|
@ -178,13 +187,23 @@ func mergeAiCredentials(defaults aiCredentials, value map[string]interface{}) ai
|
||||||
if raw, ok := value["api_keys"].(map[string]interface{}); ok {
|
if raw, ok := value["api_keys"].(map[string]interface{}); ok {
|
||||||
keys := map[string]string{}
|
keys := map[string]string{}
|
||||||
for provider, item := range raw {
|
for provider, item := range raw {
|
||||||
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
|
s, ok := item.(string)
|
||||||
keys[provider] = strings.TrimSpace(s)
|
if !ok || strings.TrimSpace(s) == "" {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
plain := strings.TrimSpace(s)
|
||||||
|
if u.cipher != nil {
|
||||||
|
decrypted, err := u.cipher.Decrypt(plain)
|
||||||
|
if err != nil {
|
||||||
|
return defaults, err
|
||||||
|
}
|
||||||
|
plain = strings.TrimSpace(decrypted)
|
||||||
|
}
|
||||||
|
keys[provider] = plain
|
||||||
}
|
}
|
||||||
defaults.ApiKeys = keys
|
defaults.ApiKeys = keys
|
||||||
}
|
}
|
||||||
return defaults
|
return defaults, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyAiSettingsPatch(current aiCredentials, patch domusecase.AiSettingsPatch) aiCredentials {
|
func applyAiSettingsPatch(current aiCredentials, patch domusecase.AiSettingsPatch) aiCredentials {
|
||||||
|
|
@ -215,10 +234,18 @@ func applyAiSettingsPatch(current aiCredentials, patch domusecase.AiSettingsPatc
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
|
|
||||||
func aiCredentialsToMap(creds aiCredentials) map[string]interface{} {
|
func (u *threadsAccountUseCase) aiCredentialsToMap(creds aiCredentials) (map[string]interface{}, error) {
|
||||||
keys := map[string]interface{}{}
|
keys := map[string]interface{}{}
|
||||||
for provider, value := range creds.ApiKeys {
|
for provider, value := range creds.ApiKeys {
|
||||||
keys[provider] = value
|
stored := value
|
||||||
|
if u.cipher != nil {
|
||||||
|
enc, err := u.cipher.Encrypt(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stored = enc
|
||||||
|
}
|
||||||
|
keys[provider] = stored
|
||||||
}
|
}
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"provider": creds.Provider,
|
"provider": creds.Provider,
|
||||||
|
|
@ -226,7 +253,7 @@ func aiCredentialsToMap(creds aiCredentials) map[string]interface{} {
|
||||||
"research_provider": creds.ResearchProvider,
|
"research_provider": creds.ResearchProvider,
|
||||||
"research_model": creds.ResearchModel,
|
"research_model": creds.ResearchModel,
|
||||||
"api_keys": keys,
|
"api_keys": keys,
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func toPublicAiSettings(accountID string, creds aiCredentials) *domusecase.AiSettings {
|
func toPublicAiSettings(accountID string, creds aiCredentials) *domusecase.AiSettings {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"haixun-backend/internal/library/clock"
|
"haixun-backend/internal/library/clock"
|
||||||
|
"haixun-backend/internal/library/crypto"
|
||||||
app "haixun-backend/internal/library/errors"
|
app "haixun-backend/internal/library/errors"
|
||||||
"haixun-backend/internal/library/errors/code"
|
"haixun-backend/internal/library/errors/code"
|
||||||
"haixun-backend/internal/library/placement"
|
"haixun-backend/internal/library/placement"
|
||||||
|
|
@ -34,6 +35,7 @@ type threadsAccountUseCase struct {
|
||||||
members memberdomain.UseCase
|
members memberdomain.UseCase
|
||||||
settings settingdomain.UseCase
|
settings settingdomain.UseCase
|
||||||
personas personadomain.UseCase
|
personas personadomain.UseCase
|
||||||
|
cipher *crypto.Cipher
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUseCase(
|
func NewUseCase(
|
||||||
|
|
@ -42,6 +44,7 @@ func NewUseCase(
|
||||||
members memberdomain.UseCase,
|
members memberdomain.UseCase,
|
||||||
settings settingdomain.UseCase,
|
settings settingdomain.UseCase,
|
||||||
personas personadomain.UseCase,
|
personas personadomain.UseCase,
|
||||||
|
cipher *crypto.Cipher,
|
||||||
) domusecase.UseCase {
|
) domusecase.UseCase {
|
||||||
return &threadsAccountUseCase{
|
return &threadsAccountUseCase{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
|
|
@ -49,6 +52,7 @@ func NewUseCase(
|
||||||
members: members,
|
members: members,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
personas: personas,
|
personas: personas,
|
||||||
|
cipher: cipher,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@ package svc
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"haixun-backend/internal/config"
|
"haixun-backend/internal/config"
|
||||||
|
libcrypto "haixun-backend/internal/library/crypto"
|
||||||
libmongo "haixun-backend/internal/library/mongo"
|
libmongo "haixun-backend/internal/library/mongo"
|
||||||
libredis "haixun-backend/internal/library/redis"
|
libredis "haixun-backend/internal/library/redis"
|
||||||
"haixun-backend/internal/library/validate"
|
"haixun-backend/internal/library/validate"
|
||||||
|
|
@ -105,6 +107,11 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
}
|
}
|
||||||
redisClient := libredis.NewClient(c.Redis)
|
redisClient := libredis.NewClient(c.Redis)
|
||||||
|
|
||||||
|
secretsCipher, err := libcrypto.New(c.Secrets.EncryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
settingRepository := settingrepo.NewMongoRepository(mongoClient.Database())
|
settingRepository := settingrepo.NewMongoRepository(mongoClient.Database())
|
||||||
if err := settingRepository.EnsureIndexes(ctx); err != nil {
|
if err := settingRepository.EnsureIndexes(ctx); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
|
@ -241,7 +248,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
scanPostRepository,
|
scanPostRepository,
|
||||||
)
|
)
|
||||||
threadsAccountRepository := threadsaccountrepo.NewMongoRepository(mongoClient.Database())
|
threadsAccountRepository := threadsaccountrepo.NewMongoRepository(mongoClient.Database())
|
||||||
threadsAccountSecretsRepository := threadsaccountrepo.NewSecretsMongoRepository(mongoClient.Database())
|
threadsAccountSecretsRepository := threadsaccountrepo.NewSecretsMongoRepository(mongoClient.Database(), secretsCipher)
|
||||||
if err := threadsAccountRepository.EnsureIndexes(ctx); err != nil {
|
if err := threadsAccountRepository.EnsureIndexes(ctx); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
@ -254,9 +261,10 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
memberUseCase,
|
memberUseCase,
|
||||||
settingUseCase,
|
settingUseCase,
|
||||||
personaUseCase,
|
personaUseCase,
|
||||||
|
secretsCipher,
|
||||||
)
|
)
|
||||||
|
|
||||||
placementUseCase := placementusecase.NewUseCase(settingUseCase)
|
placementUseCase := placementusecase.NewUseCase(settingUseCase, secretsCipher)
|
||||||
|
|
||||||
sc := &ServiceContext{
|
sc := &ServiceContext{
|
||||||
Config: c,
|
Config: c,
|
||||||
|
|
@ -375,8 +383,14 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
go reaper.Start(reaperCtx, interval)
|
go reaper.Start(reaperCtx, interval)
|
||||||
}
|
}
|
||||||
|
|
||||||
sc.AuthJWT = middleware.NewAuthJWTMiddleware(sc.AuthToken, sc.Config.Auth).Handle
|
// 認證 + RBAC:先驗 JWT(把 actor 放進 context),再用 catalog 權限做路由級授權。
|
||||||
sc.MemberAuth = middleware.NewMemberAuthMiddleware(sc.AuthToken, sc.Config.Auth).Handle
|
// 兩者組合後掛在受保護的 route group 上(routes.go 以 AuthJWT / MemberAuth 引用)。
|
||||||
|
authJWT := middleware.NewAuthJWTMiddleware(sc.AuthToken, sc.Config.Auth).Handle
|
||||||
|
memberAuth := middleware.NewMemberAuthMiddleware(sc.AuthToken, sc.Config.Auth).Handle
|
||||||
|
rbac := middleware.NewPermissionRBACMiddleware(sc.Member, sc.Permission).Handle
|
||||||
|
|
||||||
|
sc.AuthJWT = func(next http.HandlerFunc) http.HandlerFunc { return authJWT(rbac(next)) }
|
||||||
|
sc.MemberAuth = func(next http.HandlerFunc) http.HandlerFunc { return memberAuth(rbac(next)) }
|
||||||
sc.WorkerSecret = middleware.NewWorkerSecretMiddleware(sc.Config.InternalWorker).Handle
|
sc.WorkerSecret = middleware.NewWorkerSecretMiddleware(sc.Config.InternalWorker).Handle
|
||||||
|
|
||||||
return sc
|
return sc
|
||||||
|
|
|
||||||
|
|
@ -111,14 +111,16 @@ func (r *Runner) execute(ctx context.Context, run *entity.Run) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
percentage := (completedBefore * 100) / totalSteps
|
percentage := (completedBefore * 100) / totalSteps
|
||||||
_, _ = r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{
|
if _, err := r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{
|
||||||
JobID: jobID,
|
JobID: jobID,
|
||||||
WorkerID: r.workerID,
|
WorkerID: r.workerID,
|
||||||
Phase: step.ID,
|
Phase: step.ID,
|
||||||
Summary: "running step " + step.ID,
|
Summary: "running step " + step.ID,
|
||||||
Percentage: percentage,
|
Percentage: percentage,
|
||||||
Steps: steps,
|
Steps: steps,
|
||||||
})
|
}); err != nil {
|
||||||
|
log.Printf("job worker update progress (running) failed: job=%s step=%s err=%v", jobID, step.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := r.runStep(ctx, run, template, step); err != nil {
|
if err := r.runStep(ctx, run, template, step); err != nil {
|
||||||
if err == errJobCancelled {
|
if err == errJobCancelled {
|
||||||
|
|
@ -155,28 +157,32 @@ func (r *Runner) execute(ctx context.Context, run *entity.Run) {
|
||||||
}
|
}
|
||||||
completedBefore++
|
completedBefore++
|
||||||
percentage = (completedBefore * 100) / totalSteps
|
percentage = (completedBefore * 100) / totalSteps
|
||||||
_, _ = r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{
|
if _, err := r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{
|
||||||
JobID: jobID,
|
JobID: jobID,
|
||||||
WorkerID: r.workerID,
|
WorkerID: r.workerID,
|
||||||
Phase: step.ID,
|
Phase: step.ID,
|
||||||
Summary: "completed step " + step.ID,
|
Summary: "completed step " + step.ID,
|
||||||
Percentage: percentage,
|
Percentage: percentage,
|
||||||
Steps: steps,
|
Steps: steps,
|
||||||
})
|
}); err != nil {
|
||||||
|
log.Printf("job worker update progress (completed step) failed: job=%s step=%s err=%v", jobID, step.ID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fresh, err := r.jobs.GetRun(ctx, jobID)
|
fresh, err := r.jobs.GetRun(ctx, jobID)
|
||||||
if err == nil && fresh != nil && fresh.Status.IsTerminal() {
|
if err == nil && fresh != nil && fresh.Status.IsTerminal() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, _ = r.jobs.CompleteRun(ctx, domusecase.CompleteRunRequest{
|
if _, err := r.jobs.CompleteRun(ctx, domusecase.CompleteRunRequest{
|
||||||
JobID: jobID,
|
JobID: jobID,
|
||||||
WorkerID: r.workerID,
|
WorkerID: r.workerID,
|
||||||
Result: map[string]any{
|
Result: map[string]any{
|
||||||
"message": "demo long task completed",
|
"template": run.TemplateType,
|
||||||
"steps": len(template.Steps),
|
"steps": len(template.Steps),
|
||||||
},
|
},
|
||||||
})
|
}); err != nil {
|
||||||
|
log.Printf("job worker complete run failed: job=%s err=%v", jobID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var errJobCancelled = errors.New("job cancelled")
|
var errJobCancelled = errors.New("job cancelled")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,593 @@
|
||||||
|
{
|
||||||
|
"name": "haixun-node-worker",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "haixun-node-worker",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "^1.49.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.1",
|
||||||
|
"@esbuild/android-arm": "0.28.1",
|
||||||
|
"@esbuild/android-arm64": "0.28.1",
|
||||||
|
"@esbuild/android-x64": "0.28.1",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.1",
|
||||||
|
"@esbuild/darwin-x64": "0.28.1",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.1",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.1",
|
||||||
|
"@esbuild/linux-arm": "0.28.1",
|
||||||
|
"@esbuild/linux-arm64": "0.28.1",
|
||||||
|
"@esbuild/linux-ia32": "0.28.1",
|
||||||
|
"@esbuild/linux-loong64": "0.28.1",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.1",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.1",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.1",
|
||||||
|
"@esbuild/linux-s390x": "0.28.1",
|
||||||
|
"@esbuild/linux-x64": "0.28.1",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.1",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.1",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.1",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.1",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.1",
|
||||||
|
"@esbuild/sunos-x64": "0.28.1",
|
||||||
|
"@esbuild/win32-arm64": "0.28.1",
|
||||||
|
"@esbuild/win32-ia32": "0.28.1",
|
||||||
|
"@esbuild/win32-x64": "0.28.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.22.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||||
|
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.28.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { AuthProvider } from './auth/AuthContext'
|
import { AuthProvider } from './auth/AuthContext'
|
||||||
import { ThemeProvider } from './theme/ThemeContext'
|
import { ThemeProvider } from './theme/ThemeContext'
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
import { AdminRoute } from './components/AdminRoute'
|
import { AdminRoute } from './components/AdminRoute'
|
||||||
import { Layout } from './components/Layout'
|
import { Layout } from './components/Layout'
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||||
|
|
@ -37,6 +38,7 @@ export default function App() {
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<ErrorBoundary>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/register" element={<Navigate to="/login" replace />} />
|
<Route path="/register" element={<Navigate to="/login" replace />} />
|
||||||
|
|
@ -78,6 +80,7 @@ export default function App() {
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</ErrorBoundary>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ async function refreshTokens() {
|
||||||
}>
|
}>
|
||||||
if (json.code !== SUCCESS_CODE || !json.data) {
|
if (json.code !== SUCCESS_CODE || !json.data) {
|
||||||
storage.clearSession()
|
storage.clearSession()
|
||||||
|
// 通知 AuthProvider 清空狀態並導回登入。
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event('haixun:session-expired'))
|
||||||
|
}
|
||||||
throw new ApiError(json.code, json.message || 'refresh failed')
|
throw new ApiError(json.code, json.message || 'refresh failed')
|
||||||
}
|
}
|
||||||
storage.setAccessToken(json.data.access_token)
|
storage.setAccessToken(json.data.access_token)
|
||||||
|
|
@ -158,6 +162,7 @@ async function consumeAIEventStream(
|
||||||
onDelta: (text: string) => void,
|
onDelta: (text: string) => void,
|
||||||
onDone: (finishReason?: string) => void,
|
onDone: (finishReason?: string) => void,
|
||||||
onError: (msg: string) => void,
|
onError: (msg: string) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
if (!res.ok || !res.body) {
|
if (!res.ok || !res.body) {
|
||||||
onError(await readStreamErrorMessage(res))
|
onError(await readStreamErrorMessage(res))
|
||||||
|
|
@ -168,21 +173,31 @@ async function consumeAIEventStream(
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
let buffer = ''
|
let buffer = ''
|
||||||
|
|
||||||
while (true) {
|
// 確保 onDone / onError 只會被呼叫一次:避免串流提前關閉時
|
||||||
const { done, value } = await reader.read()
|
// 外層 Promise 永遠不 settle(聊天輸入永久鎖死)。
|
||||||
if (done) break
|
let settled = false
|
||||||
buffer += decoder.decode(value, { stream: true })
|
const finishDone = (finishReason?: string) => {
|
||||||
const parts = buffer.split('\n\n')
|
if (settled) return
|
||||||
buffer = parts.pop() ?? ''
|
settled = true
|
||||||
for (const part of parts) {
|
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')
|
const lines = part.split('\n')
|
||||||
let event = ''
|
let event = ''
|
||||||
let data = ''
|
const dataLines: string[] = []
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('event:')) event = line.slice(6).trim()
|
if (line.startsWith('event:')) event = line.slice(6).trim()
|
||||||
if (line.startsWith('data:')) data = line.slice(5).trim()
|
else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, ''))
|
||||||
}
|
}
|
||||||
if (!data) continue
|
const data = dataLines.join('\n').trim()
|
||||||
|
if (!data) return false
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data) as {
|
const parsed = JSON.parse(data) as {
|
||||||
type?: string
|
type?: string
|
||||||
|
|
@ -191,16 +206,44 @@ async function consumeAIEventStream(
|
||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
if (event === 'error' || parsed.type === 'error') {
|
if (event === 'error' || parsed.type === 'error') {
|
||||||
onError(parsed.message || 'stream error')
|
finishError(parsed.message || 'stream error')
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
if (parsed.type === 'delta' && parsed.text) onDelta(parsed.text)
|
if (parsed.type === 'delta' && parsed.text) onDelta(parsed.text)
|
||||||
if (parsed.type === 'done') onDone(parsed.finish_reason)
|
if (parsed.type === 'done') {
|
||||||
|
finishDone(parsed.finish_reason)
|
||||||
|
return true
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore malformed chunk */
|
/* 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(
|
export async function streamIslanderChat(
|
||||||
|
|
@ -208,16 +251,26 @@ export async function streamIslanderChat(
|
||||||
onDelta: (text: string) => void,
|
onDelta: (text: string) => void,
|
||||||
onDone: (finishReason?: string) => void,
|
onDone: (finishReason?: string) => void,
|
||||||
onError: (msg: string) => void,
|
onError: (msg: string) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
const memberToken = storage.getAccessToken()
|
const memberToken = storage.getAccessToken()
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
if (memberToken) headers.Authorization = `Bearer ${memberToken}`
|
if (memberToken) headers.Authorization = `Bearer ${memberToken}`
|
||||||
|
|
||||||
|
try {
|
||||||
const res = await fetch('/api/v1/ai/islander/chat/stream', {
|
const res = await fetch('/api/v1/ai/islander/chat/stream', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
})
|
})
|
||||||
await consumeAIEventStream(res, onDelta, onDone, onError)
|
await consumeAIEventStream(res, onDelta, onDone, onError, signal)
|
||||||
|
} catch (err) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
onDone()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onError(err instanceof Error ? err.message : '無法連線島民 API')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
refreshMember()
|
refreshMember()
|
||||||
.catch(() => storage.clearSession())
|
.catch(() => {
|
||||||
|
storage.clearSession()
|
||||||
|
setUid('')
|
||||||
|
setMember(null)
|
||||||
|
})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [refreshMember])
|
}, [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(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
tenantId,
|
tenantId,
|
||||||
|
|
@ -117,7 +132,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
register,
|
register,
|
||||||
logout,
|
logout,
|
||||||
refreshMember,
|
refreshMember,
|
||||||
isAuthenticated: !!storage.getAccessToken(),
|
// 以 uid state 推導,確保登入/登出/過期時可反應式更新(不直接讀 storage)。
|
||||||
|
isAuthenticated: !!uid,
|
||||||
}),
|
}),
|
||||||
[tenantId, uid, member, loading, setTenantId, login, register, logout, refreshMember],
|
[tenantId, uid, member, loading, setTenantId, login, register, logout, refreshMember],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
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 (
|
||||||
|
<div className="ac-slot" style={{ padding: '1.5rem', textAlign: 'center' }}>
|
||||||
|
<p className="text-ink" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
這個區塊發生問題,請重試或重新整理頁面。
|
||||||
|
</p>
|
||||||
|
<p className="text-subtle" style={{ marginBottom: '1rem', fontSize: 13 }}>
|
||||||
|
{this.state.message}
|
||||||
|
</p>
|
||||||
|
<button type="button" className="ac-btn-secondary" onClick={this.handleReset}>
|
||||||
|
重試
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { api } from '../api/client'
|
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 { jobTemplateLabel } from '../lib/jobTemplate'
|
||||||
import { ANALYZE_COPY_MISSION_PIPELINE_STEPS, VIRAL_SCAN_PIPELINE_STEPS } from '../lib/copyFlow'
|
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'
|
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(() => {
|
useEffect(() => {
|
||||||
load().catch(() => undefined)
|
load().catch(() => undefined)
|
||||||
const timer = window.setInterval(() => load().catch(() => undefined), 2000)
|
// 有進行中任務時每 3 秒刷新;否則放慢到 20 秒(仍能撈到別處啟動的任務)。
|
||||||
return () => window.clearInterval(timer)
|
// 分頁切到背景時暫停輪詢,避免無謂的流量與後端負載。
|
||||||
}, [load])
|
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(() => {
|
const syncClampedPosition = useCallback(() => {
|
||||||
setPosition((prev) => {
|
setPosition((prev) => {
|
||||||
|
|
|
||||||
|
|
@ -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 { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { api, streamIslanderChat } from '../../api/client'
|
import { api, streamIslanderChat } from '../../api/client'
|
||||||
import {
|
import {
|
||||||
|
|
@ -47,6 +47,14 @@ export function IslanderCompanion() {
|
||||||
|
|
||||||
const pageMeta = pageCtx?.runtimeMeta ?? null
|
const pageMeta = pageCtx?.runtimeMeta ?? null
|
||||||
|
|
||||||
|
// 取消尚在進行的 SSE 串流(送出新訊息或元件卸載時)。
|
||||||
|
const streamAbortRef = useRef<AbortController | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
streamAbortRef.current?.abort()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const setExpandedPersisted = useCallback((value: boolean | ((prev: boolean) => boolean)) => {
|
const setExpandedPersisted = useCallback((value: boolean | ((prev: boolean) => boolean)) => {
|
||||||
setExpanded((prev) => {
|
setExpanded((prev) => {
|
||||||
const next = typeof value === 'function' ? value(prev) : value
|
const next = typeof value === 'function' ? value(prev) : value
|
||||||
|
|
@ -89,6 +97,10 @@ export function IslanderCompanion() {
|
||||||
setStreaming(true)
|
setStreaming(true)
|
||||||
setActing(false)
|
setActing(false)
|
||||||
|
|
||||||
|
streamAbortRef.current?.abort()
|
||||||
|
const abortController = new AbortController()
|
||||||
|
streamAbortRef.current = abortController
|
||||||
|
|
||||||
const userMessage: IslanderChatMessage = {
|
const userMessage: IslanderChatMessage = {
|
||||||
id: nextMessageId(),
|
id: nextMessageId(),
|
||||||
role: 'user',
|
role: 'user',
|
||||||
|
|
@ -141,6 +153,7 @@ export function IslanderCompanion() {
|
||||||
(delta) => onDelta(delta),
|
(delta) => onDelta(delta),
|
||||||
() => resolve(),
|
() => resolve(),
|
||||||
(message) => reject(new Error(message)),
|
(message) => reject(new Error(message)),
|
||||||
|
abortController.signal,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
const markdownComponents: Components = {
|
const markdownComponents: Components = {
|
||||||
a: ({ href, children }) => {
|
a: ({ href, children }) => {
|
||||||
if (href?.startsWith('/')) {
|
// 僅單斜線開頭視為站內路徑;`//evil.com` 等 protocol-relative 連結走外部分支,避免 open redirect。
|
||||||
|
if (href?.startsWith('/') && !href.startsWith('//')) {
|
||||||
return (
|
return (
|
||||||
<Link to={href} className="ac-link font-semibold">
|
<Link to={href} className="ac-link font-semibold">
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,11 @@ export function normalizeExtensionSyncResult(raw: ExtensionSyncResult): Extensio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 內容腳本注入在本頁,與本頁同 origin;限定 targetOrigin 避免把訊息(含 JWT)廣播給其他來源。
|
||||||
|
const SAME_ORIGIN = window.location.origin
|
||||||
|
|
||||||
export function pingExtensionBridge() {
|
export function pingExtensionBridge() {
|
||||||
window.postMessage({ type: 'HAIXUN_PING_EXTENSION' }, '*')
|
window.postMessage({ type: 'HAIXUN_PING_EXTENSION' }, SAME_ORIGIN)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function waitForExtensionBridge(timeoutMs = 8000): Promise<boolean> {
|
export function waitForExtensionBridge(timeoutMs = 8000): Promise<boolean> {
|
||||||
|
|
@ -39,6 +42,7 @@ export function waitForExtensionBridge(timeoutMs = 8000): Promise<boolean> {
|
||||||
const started = Date.now()
|
const started = Date.now()
|
||||||
|
|
||||||
const onMessage = (event: MessageEvent) => {
|
const onMessage = (event: MessageEvent) => {
|
||||||
|
if (event.origin !== SAME_ORIGIN) return
|
||||||
if (event.source !== window) return
|
if (event.source !== window) return
|
||||||
if (event.data?.type !== 'HAIXUN_EXTENSION_READY') return
|
if (event.data?.type !== 'HAIXUN_EXTENSION_READY') return
|
||||||
cleanup()
|
cleanup()
|
||||||
|
|
@ -84,6 +88,7 @@ export function requestExtensionSync(input: {
|
||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
function onMessage(event: MessageEvent) {
|
function onMessage(event: MessageEvent) {
|
||||||
|
if (event.origin !== SAME_ORIGIN) return
|
||||||
if (event.source !== window) return
|
if (event.source !== window) return
|
||||||
if (event.data?.type !== 'HAIXUN_THREADS_SYNC_RESULT') return
|
if (event.data?.type !== 'HAIXUN_THREADS_SYNC_RESULT') return
|
||||||
window.clearTimeout(timeout)
|
window.clearTimeout(timeout)
|
||||||
|
|
@ -100,7 +105,7 @@ export function requestExtensionSync(input: {
|
||||||
accessToken: input.accessToken,
|
accessToken: input.accessToken,
|
||||||
apiVersion: 'go-v1',
|
apiVersion: 'go-v1',
|
||||||
},
|
},
|
||||||
'*',
|
SAME_ORIGIN,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -189,6 +189,18 @@ type ExecuteOptions = {
|
||||||
snapshot?: PageSnapshot
|
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<{
|
export async function executeIslanderActions(opts: ExecuteOptions): Promise<{
|
||||||
results: IslanderActionResult[]
|
results: IslanderActionResult[]
|
||||||
snapshotText: string
|
snapshotText: string
|
||||||
|
|
@ -197,6 +209,10 @@ export async function executeIslanderActions(opts: ExecuteOptions): Promise<{
|
||||||
const results: IslanderActionResult[] = []
|
const results: IslanderActionResult[] = []
|
||||||
|
|
||||||
for (const action of opts.actions) {
|
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)
|
const result = await runAction(action, opts.navigate, snapshot)
|
||||||
results.push(result)
|
results.push(result)
|
||||||
if (!result.ok && action.type !== 'wait') break
|
if (!result.ok && action.type !== 'wait') break
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,18 @@ export const ISLANDER_CONFIG = {
|
||||||
`[data-islander-label]`,
|
`[data-islander-label]`,
|
||||||
],
|
],
|
||||||
blockedClickPatterns: [/登出/i, /logout/i],
|
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[],
|
defaultSuggestions: [] as string[],
|
||||||
highlightClass: 'ac-islander-target-highlight',
|
highlightClass: 'ac-islander-target-highlight',
|
||||||
highlightDurationMs: 1800,
|
highlightDurationMs: 1800,
|
||||||
|
|
|
||||||
|
|
@ -25,5 +25,8 @@ export const storage = {
|
||||||
localStorage.removeItem(KEYS.refreshToken)
|
localStorage.removeItem(KEYS.refreshToken)
|
||||||
localStorage.removeItem(KEYS.uid)
|
localStorage.removeItem(KEYS.uid)
|
||||||
localStorage.removeItem(KEYS.activeThreadsAccountId)
|
localStorage.removeItem(KEYS.activeThreadsAccountId)
|
||||||
|
// 高敏感的 AI provider key 不可在登出後殘留(共用裝置)。
|
||||||
|
localStorage.removeItem(KEYS.aiProviderToken)
|
||||||
|
localStorage.removeItem(KEYS.tenantId)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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(機敏資料落地加密)
|
||||||
|
```
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue