From ffd6a31d6fb1bf73a60f86e6f5b2ca11f9da2db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Sat, 27 Jun 2026 00:02:06 +0800 Subject: [PATCH] fix2 --- .DS_Store | Bin 8196 -> 10244 bytes .gitignore | 15 + Makefile | 174 +++++ backend/.DS_Store | Bin 8196 -> 8196 bytes backend/cmd/tool/main.go | 6 +- backend/cmd/worker/main.go | 4 +- backend/etc/gateway.prod.yaml | 20 +- backend/etc/gateway.worker.prod.yaml | 39 ++ backend/etc/gateway.worker.yaml | 17 +- backend/etc/gateway.yaml | 13 +- backend/gateway.go | 2 +- backend/internal/bootstrap/init.go | 28 + backend/internal/config/config.go | 14 +- backend/internal/library/crypto/aesgcm.go | 99 +++ backend/internal/library/redis/client.go | 5 +- backend/internal/logic/setting/authz.go | 41 ++ .../logic/setting/delete_setting_logic.go | 3 + .../logic/setting/get_setting_logic.go | 3 + .../logic/setting/list_settings_logic.go | 3 + .../logic/setting/upsert_setting_logic.go | 3 + .../internal/middleware/auth_middleware.go | 22 - .../middleware/permissionrbac_middleware.go | 9 +- .../middleware/workersecret_middleware.go | 16 +- backend/internal/model/auth/usecase/token.go | 12 +- .../model/permission/usecase/usecase_test.go | 2 +- .../model/placement/usecase/settings.go | 44 +- .../repository/secrets_mongo.go | 27 +- .../threads_account/usecase/ai_credentials.go | 51 +- .../model/threads_account/usecase/usecase.go | 4 + backend/internal/svc/service_context.go | 22 +- backend/internal/worker/job/runner.go | 22 +- backend/worker/package-lock.json | 593 ++++++++++++++++++ frontend/src/App.tsx | 3 + frontend/src/api/client.ts | 123 ++-- frontend/src/auth/AuthContext.tsx | 20 +- frontend/src/components/ErrorBoundary.tsx | 50 ++ frontend/src/components/JobMonitor.tsx | 25 +- .../components/islander/IslanderCompanion.tsx | 15 +- .../components/islander/IslanderMarkdown.tsx | 3 +- frontend/src/lib/extensionSync.ts | 9 +- frontend/src/lib/islander/actionExecutor.ts | 16 + frontend/src/lib/islander/config.ts | 12 + frontend/src/lib/storage.ts | 3 + infra/.env.example | 12 + infra/README.md | 103 +++ infra/docker-compose.yml | 42 ++ infra/etc/haixun.env.example | 23 + infra/nginx/haixun.conf | 56 ++ infra/systemd/haixun-gateway.service | 25 + infra/systemd/haixun-node-worker.service | 23 + infra/systemd/haixun-worker.service | 23 + 51 files changed, 1762 insertions(+), 137 deletions(-) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 backend/etc/gateway.worker.prod.yaml create mode 100644 backend/internal/library/crypto/aesgcm.go create mode 100644 backend/internal/logic/setting/authz.go delete mode 100644 backend/internal/middleware/auth_middleware.go create mode 100644 backend/worker/package-lock.json create mode 100644 frontend/src/components/ErrorBoundary.tsx create mode 100644 infra/.env.example create mode 100644 infra/README.md create mode 100644 infra/docker-compose.yml create mode 100644 infra/etc/haixun.env.example create mode 100644 infra/nginx/haixun.conf create mode 100644 infra/systemd/haixun-gateway.service create mode 100644 infra/systemd/haixun-node-worker.service create mode 100644 infra/systemd/haixun-worker.service diff --git a/.DS_Store b/.DS_Store index cae0d5cd8b171ebf082feb40899c59065fbc2153..8d07aef3e7f58514f024624d091b16e033258c3c 100644 GIT binary patch delta 317 zcmZp1XbF&DU|?W$DortDU{C-uIe-{M3-C-V6q~50$SAfkU^hRb*km37O~!eX%>*=A zoq*DMn^Od4G4pN$G5>=B1HTP frontend/dist + cd $(FRONTEND_DIR) && npm ci && npm run build + +.PHONY: build-backend +build-backend: ## [prod] 交叉編譯 gateway + worker -> backend/bin (linux) + cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \ + go build -trimpath -ldflags "-s -w" -o bin/gateway . + cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \ + go build -trimpath -ldflags "-s -w" -o bin/worker ./cmd/worker + cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \ + go build -trimpath -ldflags "-s -w" -o bin/tool ./cmd/tool + @echo "binary 已輸出到 $(BIN_DIR)/(gateway / worker / tool)" + +# ============================================================ +# PROD (部署) +# ============================================================ + +.PHONY: prod-infra +prod-infra: $(INFRA_DIR)/.env ## [prod] 起 Mongo + Redis (docker,背景) + $(COMPOSE) up -d + +.PHONY: prod-infra-down +prod-infra-down: ## [prod] 停掉 Mongo + Redis + $(COMPOSE) down + +.PHONY: prod-init +prod-init: ## [prod] 目標主機初始化 DB + 建立 admin(讀 /opt/haixun/etc/haixun.env;可加 INIT_ADMIN_EMAIL/PASSWORD) + @test -x $(DEPLOY_ROOT)/bin/tool || (echo "缺少 $(DEPLOY_ROOT)/bin/tool,請先 make install" && exit 1) + set -a; . $(DEPLOY_ROOT)/etc/haixun.env; set +a; \ + $(DEPLOY_ROOT)/bin/tool init -f $(DEPLOY_ROOT)/etc/gateway.prod.yaml + +.PHONY: install +install: ## [prod] 安裝 binary/前端/設定/systemd/nginx(需 root,在目標主機執行) + @test -f $(BIN_DIR)/gateway || (echo "缺少 $(BIN_DIR)/gateway,請先在能 build 的機器執行 make build" && exit 1) + @test -d $(FRONTEND_DIR)/dist || (echo "缺少 $(FRONTEND_DIR)/dist,請先 make build-frontend" && exit 1) + id haixun >/dev/null 2>&1 || useradd --system --home $(DEPLOY_ROOT) --shell /usr/sbin/nologin haixun + install -d $(DEPLOY_ROOT)/bin $(DEPLOY_ROOT)/etc $(DEPLOY_ROOT)/node-worker $(WEB_ROOT) + install -m 0755 $(BIN_DIR)/gateway $(DEPLOY_ROOT)/bin/gateway + install -m 0755 $(BIN_DIR)/worker $(DEPLOY_ROOT)/bin/worker + install -m 0755 $(BIN_DIR)/tool $(DEPLOY_ROOT)/bin/tool + install -m 0644 $(BACKEND_DIR)/etc/gateway.prod.yaml $(DEPLOY_ROOT)/etc/gateway.prod.yaml + install -m 0644 $(BACKEND_DIR)/etc/gateway.worker.prod.yaml $(DEPLOY_ROOT)/etc/gateway.worker.prod.yaml + rm -rf $(WEB_ROOT)/* && cp -r $(FRONTEND_DIR)/dist/* $(WEB_ROOT)/ + cp -r $(BACKEND_DIR)/worker/* $(DEPLOY_ROOT)/node-worker/ + cd $(DEPLOY_ROOT)/node-worker && npm ci && npx playwright install --with-deps chromium + install -m 0644 $(INFRA_DIR)/systemd/haixun-gateway.service /etc/systemd/system/haixun-gateway.service + install -m 0644 $(INFRA_DIR)/systemd/haixun-worker.service /etc/systemd/system/haixun-worker.service + install -m 0644 $(INFRA_DIR)/systemd/haixun-node-worker.service /etc/systemd/system/haixun-node-worker.service + install -m 0644 $(INFRA_DIR)/nginx/haixun.conf /etc/nginx/conf.d/haixun.conf + chown -R haixun:haixun $(DEPLOY_ROOT) $(WEB_ROOT) + @echo "----" + @echo "接著(只做一次)建立 secret 檔:" + @echo " cp $(INFRA_DIR)/etc/haixun.env.example $(DEPLOY_ROOT)/etc/haixun.env && chmod 600 $(DEPLOY_ROOT)/etc/haixun.env && sudoedit $(DEPLOY_ROOT)/etc/haixun.env" + @echo "再啟用服務:" + @echo " systemctl daemon-reload && systemctl enable --now haixun-gateway haixun-worker haixun-node-worker" + @echo " nginx -t && systemctl reload nginx" + +# ============================================================ +# 驗證 / 維護 +# ============================================================ + +.PHONY: tidy +tidy: ## go mod tidy + cd $(BACKEND_DIR) && go mod tidy + +.PHONY: fmt +fmt: ## gofmt 後端 + cd $(BACKEND_DIR) && gofmt -w . + +.PHONY: test +test: ## 跑後端測試 + cd $(BACKEND_DIR) && go test ./... + +.PHONY: verify +verify: ## 後端 build/test + 前端 build + compose 語法 + cd $(BACKEND_DIR) && go build ./... && go test ./... + cd $(FRONTEND_DIR) && npm ci && npm run build + $(COMPOSE) config >/dev/null && echo "docker compose config OK" diff --git a/backend/.DS_Store b/backend/.DS_Store index 438083423bf4bec3d4e830d38faa44c1e6106d17..3397521d4d5052c563c944fb23e80fffc04c6401 100644 GIT binary patch delta 142 zcmZp1XmQw}D!}T*z`&q4SxZ1khhYF=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 737ed96..f897e37 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import { AuthProvider } from './auth/AuthContext' import { ThemeProvider } from './theme/ThemeContext' +import { ErrorBoundary } from './components/ErrorBoundary' import { AdminRoute } from './components/AdminRoute' import { Layout } from './components/Layout' import { ProtectedRoute } from './components/ProtectedRoute' @@ -37,6 +38,7 @@ export default function App() { + } /> } /> @@ -78,6 +80,7 @@ export default function App() { } /> + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 10a4524..1ab0ffa 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -35,6 +35,10 @@ async function refreshTokens() { }> if (json.code !== SUCCESS_CODE || !json.data) { storage.clearSession() + // 通知 AuthProvider 清空狀態並導回登入。 + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('haixun:session-expired')) + } throw new ApiError(json.code, json.message || 'refresh failed') } storage.setAccessToken(json.data.access_token) @@ -158,6 +162,7 @@ async function consumeAIEventStream( onDelta: (text: string) => void, onDone: (finishReason?: string) => void, onError: (msg: string) => void, + signal?: AbortSignal, ) { if (!res.ok || !res.body) { onError(await readStreamErrorMessage(res)) @@ -168,39 +173,77 @@ async function consumeAIEventStream( const decoder = new TextDecoder() let buffer = '' - while (true) { - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - const parts = buffer.split('\n\n') - buffer = parts.pop() ?? '' - for (const part of parts) { - const lines = part.split('\n') - let event = '' - let data = '' - for (const line of lines) { - if (line.startsWith('event:')) event = line.slice(6).trim() - if (line.startsWith('data:')) data = line.slice(5).trim() + // 確保 onDone / onError 只會被呼叫一次:避免串流提前關閉時 + // 外層 Promise 永遠不 settle(聊天輸入永久鎖死)。 + let settled = false + const finishDone = (finishReason?: string) => { + if (settled) return + settled = true + onDone(finishReason) + } + const finishError = (msg: string) => { + if (settled) return + settled = true + onError(msg) + } + + // 回傳 true 代表收到 done/error,串流應停止。 + const handlePart = (part: string): boolean => { + const lines = part.split('\n') + let event = '' + const dataLines: string[] = [] + for (const line of lines) { + if (line.startsWith('event:')) event = line.slice(6).trim() + else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, '')) + } + const data = dataLines.join('\n').trim() + if (!data) return false + try { + const parsed = JSON.parse(data) as { + type?: string + text?: string + finish_reason?: string + message?: string } - if (!data) continue - try { - const parsed = JSON.parse(data) as { - type?: string - text?: string - finish_reason?: string - message?: string - } - if (event === 'error' || parsed.type === 'error') { - onError(parsed.message || 'stream error') - return - } - if (parsed.type === 'delta' && parsed.text) onDelta(parsed.text) - if (parsed.type === 'done') onDone(parsed.finish_reason) - } catch { - /* ignore malformed chunk */ + if (event === 'error' || parsed.type === 'error') { + finishError(parsed.message || 'stream error') + return true + } + if (parsed.type === 'delta' && parsed.text) onDelta(parsed.text) + if (parsed.type === 'done') { + finishDone(parsed.finish_reason) + return true + } + } catch { + /* ignore malformed chunk */ + } + return false + } + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const parts = buffer.split('\n\n') + buffer = parts.pop() ?? '' + for (const part of parts) { + if (handlePart(part)) return } } + // 沖出殘餘 buffer(最後一段可能沒有結尾的空行)。 + if (buffer.trim()) handlePart(buffer) + } catch (err) { + if (signal?.aborted) { + finishDone() + return + } + finishError(err instanceof Error ? err.message : '串流連線中斷') + return } + + // reader 正常結束但未收到明確的 done 事件:仍要 settle,避免卡死。 + finishDone() } export async function streamIslanderChat( @@ -208,16 +251,26 @@ export async function streamIslanderChat( onDelta: (text: string) => void, onDone: (finishReason?: string) => void, onError: (msg: string) => void, + signal?: AbortSignal, ) { const memberToken = storage.getAccessToken() const headers: Record = { 'Content-Type': 'application/json' } if (memberToken) headers.Authorization = `Bearer ${memberToken}` - const res = await fetch('/api/v1/ai/islander/chat/stream', { - method: 'POST', - headers, - body: JSON.stringify(body), - }) - await consumeAIEventStream(res, onDelta, onDone, onError) + try { + const res = await fetch('/api/v1/ai/islander/chat/stream', { + method: 'POST', + headers, + body: JSON.stringify(body), + signal, + }) + await consumeAIEventStream(res, onDelta, onDone, onError, signal) + } catch (err) { + if (signal?.aborted) { + onDone() + return + } + onError(err instanceof Error ? err.message : '無法連線島民 API') + } } diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx index 3007fc9..54690fb 100644 --- a/frontend/src/auth/AuthContext.tsx +++ b/frontend/src/auth/AuthContext.tsx @@ -64,7 +64,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { return } refreshMember() - .catch(() => storage.clearSession()) + .catch(() => { + storage.clearSession() + setUid('') + setMember(null) + }) .finally(() => setLoading(false)) }, [refreshMember]) @@ -106,6 +110,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, []) + // 當 client.ts 的 refresh 失敗(session 過期)時清空狀態,讓 ProtectedRoute 導回登入。 + useEffect(() => { + const onExpired = () => { + storage.clearSession() + setMember(null) + setUid('') + } + window.addEventListener('haixun:session-expired', onExpired) + return () => window.removeEventListener('haixun:session-expired', onExpired) + }, []) + const value = useMemo( () => ({ tenantId, @@ -117,7 +132,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { register, logout, refreshMember, - isAuthenticated: !!storage.getAccessToken(), + // 以 uid state 推導,確保登入/登出/過期時可反應式更新(不直接讀 storage)。 + isAuthenticated: !!uid, }), [tenantId, uid, member, loading, setTenantId, login, register, logout, refreshMember], ) diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..cb9fd9f --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,50 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react' + +type ErrorBoundaryProps = { + children: ReactNode + fallback?: ReactNode +} + +type ErrorBoundaryState = { + hasError: boolean + message: string +} + +/** + * 攔截子樹的 render 例外,避免單一元件(例如渲染 AI 任意輸出的 IslanderMarkdown) + * 出錯就整頁白屏。 + */ +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { hasError: false, message: '' } + + static getDerivedStateFromError(error: unknown): ErrorBoundaryState { + return { hasError: true, message: error instanceof Error ? error.message : '發生未預期錯誤' } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('ErrorBoundary caught error:', error, info.componentStack) + } + + private handleReset = () => { + this.setState({ hasError: false, message: '' }) + } + + render() { + if (!this.state.hasError) return this.props.children + if (this.props.fallback) return this.props.fallback + + return ( +
+

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

+

+ {this.state.message} +

+ +
+ ) + } +} diff --git a/frontend/src/components/JobMonitor.tsx b/frontend/src/components/JobMonitor.tsx index b32033c..be1d83b 100644 --- a/frontend/src/components/JobMonitor.tsx +++ b/frontend/src/components/JobMonitor.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' import { api } from '../api/client' -import { isTerminalJobStatus, jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus' +import { isActiveJobStatus, isTerminalJobStatus, jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus' import { jobTemplateLabel } from '../lib/jobTemplate' import { ANALYZE_COPY_MISSION_PIPELINE_STEPS, VIRAL_SCAN_PIPELINE_STEPS } from '../lib/copyFlow' import { EXPAND_GRAPH_PIPELINE_STEPS, PLACEMENT_SCAN_PIPELINE_STEPS } from '../lib/knowledgeGraph' @@ -347,11 +347,28 @@ export function JobMonitor() { } }, []) + const hasActiveJob = jobs.some((job) => isActiveJobStatus(job.status)) + useEffect(() => { load().catch(() => undefined) - const timer = window.setInterval(() => load().catch(() => undefined), 2000) - return () => window.clearInterval(timer) - }, [load]) + // 有進行中任務時每 3 秒刷新;否則放慢到 20 秒(仍能撈到別處啟動的任務)。 + // 分頁切到背景時暫停輪詢,避免無謂的流量與後端負載。 + const intervalMs = hasActiveJob ? 3000 : 20000 + const timer = window.setInterval(() => { + if (document.visibilityState === 'hidden') return + load().catch(() => undefined) + }, intervalMs) + + const onVisible = () => { + if (document.visibilityState === 'visible') load().catch(() => undefined) + } + document.addEventListener('visibilitychange', onVisible) + + return () => { + window.clearInterval(timer) + document.removeEventListener('visibilitychange', onVisible) + } + }, [load, hasActiveJob]) const syncClampedPosition = useCallback(() => { setPosition((prev) => { diff --git a/frontend/src/components/islander/IslanderCompanion.tsx b/frontend/src/components/islander/IslanderCompanion.tsx index 2ee8f84..7b559ea 100644 --- a/frontend/src/components/islander/IslanderCompanion.tsx +++ b/frontend/src/components/islander/IslanderCompanion.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { api, streamIslanderChat } from '../../api/client' import { @@ -47,6 +47,14 @@ export function IslanderCompanion() { const pageMeta = pageCtx?.runtimeMeta ?? null + // 取消尚在進行的 SSE 串流(送出新訊息或元件卸載時)。 + const streamAbortRef = useRef(null) + useEffect(() => { + return () => { + streamAbortRef.current?.abort() + } + }, []) + const setExpandedPersisted = useCallback((value: boolean | ((prev: boolean) => boolean)) => { setExpanded((prev) => { const next = typeof value === 'function' ? value(prev) : value @@ -89,6 +97,10 @@ export function IslanderCompanion() { setStreaming(true) setActing(false) + streamAbortRef.current?.abort() + const abortController = new AbortController() + streamAbortRef.current = abortController + const userMessage: IslanderChatMessage = { id: nextMessageId(), role: 'user', @@ -141,6 +153,7 @@ export function IslanderCompanion() { (delta) => onDelta(delta), () => resolve(), (message) => reject(new Error(message)), + abortController.signal, ) }) }, diff --git a/frontend/src/components/islander/IslanderMarkdown.tsx b/frontend/src/components/islander/IslanderMarkdown.tsx index 6c418da..bf764fd 100644 --- a/frontend/src/components/islander/IslanderMarkdown.tsx +++ b/frontend/src/components/islander/IslanderMarkdown.tsx @@ -5,7 +5,8 @@ import { Link } from 'react-router-dom' const markdownComponents: Components = { a: ({ href, children }) => { - if (href?.startsWith('/')) { + // 僅單斜線開頭視為站內路徑;`//evil.com` 等 protocol-relative 連結走外部分支,避免 open redirect。 + if (href?.startsWith('/') && !href.startsWith('//')) { return ( {children} diff --git a/frontend/src/lib/extensionSync.ts b/frontend/src/lib/extensionSync.ts index d241c7c..51329a8 100644 --- a/frontend/src/lib/extensionSync.ts +++ b/frontend/src/lib/extensionSync.ts @@ -28,8 +28,11 @@ export function normalizeExtensionSyncResult(raw: ExtensionSyncResult): Extensio } } +// 內容腳本注入在本頁,與本頁同 origin;限定 targetOrigin 避免把訊息(含 JWT)廣播給其他來源。 +const SAME_ORIGIN = window.location.origin + export function pingExtensionBridge() { - window.postMessage({ type: 'HAIXUN_PING_EXTENSION' }, '*') + window.postMessage({ type: 'HAIXUN_PING_EXTENSION' }, SAME_ORIGIN) } export function waitForExtensionBridge(timeoutMs = 8000): Promise { @@ -39,6 +42,7 @@ export function waitForExtensionBridge(timeoutMs = 8000): Promise { const started = Date.now() const onMessage = (event: MessageEvent) => { + if (event.origin !== SAME_ORIGIN) return if (event.source !== window) return if (event.data?.type !== 'HAIXUN_EXTENSION_READY') return cleanup() @@ -84,6 +88,7 @@ export function requestExtensionSync(input: { }, 30000) function onMessage(event: MessageEvent) { + if (event.origin !== SAME_ORIGIN) return if (event.source !== window) return if (event.data?.type !== 'HAIXUN_THREADS_SYNC_RESULT') return window.clearTimeout(timeout) @@ -100,7 +105,7 @@ export function requestExtensionSync(input: { accessToken: input.accessToken, apiVersion: 'go-v1', }, - '*', + SAME_ORIGIN, ) }) } \ No newline at end of file diff --git a/frontend/src/lib/islander/actionExecutor.ts b/frontend/src/lib/islander/actionExecutor.ts index 618fee5..2fbe37f 100644 --- a/frontend/src/lib/islander/actionExecutor.ts +++ b/frontend/src/lib/islander/actionExecutor.ts @@ -189,6 +189,18 @@ type ExecuteOptions = { snapshot?: PageSnapshot } +function isDangerousAction(type: string): boolean { + return (ISLANDER_CONFIG.dangerousActionTypes as readonly string[]).includes(type) +} + +// 對危險動作要求真人確認;AI 自填的 confirm 不足以放行。 +function confirmDangerousAction(type: string): boolean { + if (typeof window === 'undefined' || typeof window.confirm !== 'function') return false + return window.confirm( + `島民想替你執行「${type}」,這會實際送出 / 發布或啟動背景任務。確定要執行嗎?`, + ) +} + export async function executeIslanderActions(opts: ExecuteOptions): Promise<{ results: IslanderActionResult[] snapshotText: string @@ -197,6 +209,10 @@ export async function executeIslanderActions(opts: ExecuteOptions): Promise<{ const results: IslanderActionResult[] = [] for (const action of opts.actions) { + if (isDangerousAction(action.type) && !confirmDangerousAction(action.type)) { + results.push({ action, ok: false, detail: '使用者未確認,已略過此操作' }) + break + } const result = await runAction(action, opts.navigate, snapshot) results.push(result) if (!result.ok && action.type !== 'wait') break diff --git a/frontend/src/lib/islander/config.ts b/frontend/src/lib/islander/config.ts index cd9262e..29bcb8c 100644 --- a/frontend/src/lib/islander/config.ts +++ b/frontend/src/lib/islander/config.ts @@ -38,6 +38,18 @@ export const ISLANDER_CONFIG = { `[data-islander-label]`, ], blockedClickPatterns: [/登出/i, /logout/i], + // 這些 action 會實際對外發布、啟動背景/付費任務或寫入後端, + // 必須由真人確認(window.confirm),不能只靠 AI 在 JSON 自填 confirm。 + // 避免被海巡抓回的不可信貼文做 prompt injection 觸發自動發文。 + dangerousActionTypes: [ + 'publishOutreach', + 'publishCopyDraft', + 'startViralScan', + 'startCopyMissionAnalyze', + 'startCopyMissionScan', + 'generateCopyMatrix', + 'generateCopyDraft', + ] as string[], defaultSuggestions: [] as string[], highlightClass: 'ac-islander-target-highlight', highlightDurationMs: 1800, diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index a86998d..b1d96dd 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -25,5 +25,8 @@ export const storage = { localStorage.removeItem(KEYS.refreshToken) localStorage.removeItem(KEYS.uid) localStorage.removeItem(KEYS.activeThreadsAccountId) + // 高敏感的 AI provider key 不可在登出後殘留(共用裝置)。 + localStorage.removeItem(KEYS.aiProviderToken) + localStorage.removeItem(KEYS.tenantId) }, } \ No newline at end of file diff --git a/infra/.env.example b/infra/.env.example new file mode 100644 index 0000000..d0a5704 --- /dev/null +++ b/infra/.env.example @@ -0,0 +1,12 @@ +# Infra docker compose 環境變數範本 +# 複製成 infra/.env 後填入實際值;.env 不要 commit。 + +# --- Mongo --- +MONGO_PORT=27017 +MONGO_ROOT_USER=haixun +MONGO_ROOT_PASSWORD=change-me-mongo-pass +MONGO_DATABASE=haixun + +# --- Redis --- +REDIS_PORT=6379 +REDIS_PASSWORD=change-me-redis-pass diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..afb57fd --- /dev/null +++ b/infra/README.md @@ -0,0 +1,103 @@ +# 巡樓部署 (infra) + +部署拓樸: + +``` +瀏覽器 → nginx(systemd, :80/:443) + ├─ 靜態前端 /var/www/haixun (frontend/dist) + └─ /api 反向代理 → Go gateway (systemd, 127.0.0.1:8890) +Go gateway / Go worker (systemd) → Mongo / Redis (docker compose, 綁 127.0.0.1) +Node playwright worker (systemd) → 透過 HTTP 打 gateway +``` + +- 資料服務(Mongo/Redis)用 docker compose,只綁 `127.0.0.1`。 +- Go gateway / Go worker / Node worker 都是 systemd 原生服務。 +- secret 一律放 `/opt/haixun/etc/haixun.env`(不進 repo),yaml 用 `${VAR}` 讀取。 + +## 目錄 + +``` +infra/ + docker-compose.yml # mongo + redis + .env.example # compose 用環境變數 + etc/haixun.env.example # systemd EnvironmentFile 範本(secret) + nginx/haixun.conf # 靜態前端 + /api 反代 + SSE + systemd/ + haixun-gateway.service + haixun-worker.service + haixun-node-worker.service +``` + +## 1. 起資料服務 (docker) + +```bash +cd infra +cp .env.example .env # 填入 Mongo/Redis 密碼 +docker compose --env-file .env up -d +docker compose ps +``` + +## 2. 建置產物(本機或 CI) + +```bash +make build # 前端 dist + 兩個 linux Go binary(backend/bin/) +``` + +## 3. 安裝到目標主機 + +於目標主機(需 root): + +```bash +sudo make install +``` + +`make install` 會: + +1. 建立使用者 `haixun` 與目錄 `/opt/haixun/{bin,etc,node-worker}`、`/var/www/haixun`。 +2. 複製 `backend/bin/{gateway,worker}`、`backend/etc/gateway.prod.yaml`、`backend/etc/gateway.worker.prod.yaml`。 +3. 複製 `frontend/dist/*` → `/var/www/haixun`。 +4. 複製 `backend/worker/*`(Node worker)→ `/opt/haixun/node-worker`,並 `npm ci` + `npx playwright install`。 +5. 安裝 `infra/systemd/*.service` 與 `infra/nginx/haixun.conf`。 + +接著手動建立 secret 檔(**只做一次**): + +```bash +sudo cp infra/etc/haixun.env.example /opt/haixun/etc/haixun.env +sudo chmod 600 /opt/haixun/etc/haixun.env +sudoedit /opt/haixun/etc/haixun.env # 填入實際 secret +``` + +## 4. 初始化資料庫與 admin 帳號(只做一次) + +Mongo 起來、secret 填好後,建立索引 / 權限 catalog / role_permissions,並建立第一個 admin: + +```bash +# 可在 haixun.env 內設定 INIT_ADMIN_EMAIL / INIT_ADMIN_PASSWORD,或在這裡用環境變數覆寫 +sudo make prod-init +# 等同:source /opt/haixun/etc/haixun.env 後執行 /opt/haixun/bin/tool init -f /opt/haixun/etc/gateway.prod.yaml +``` + +之後一般使用者可走 `POST /api/v1/auth/register` 自助註冊(前端登入頁)。 + +## 5. 啟用服務 + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now haixun-gateway haixun-worker haixun-node-worker +sudo nginx -t && sudo systemctl reload nginx +``` + +## 6. 健康檢查 + +```bash +curl http://127.0.0.1:8890/api/v1/health +sudo systemctl status haixun-gateway haixun-worker haixun-node-worker +journalctl -u haixun-gateway -f +``` + +## 產生 secret + +```bash +openssl rand -base64 48 # JWT access / refresh / worker secret +openssl rand -base64 32 # HAIXUN_SECRETS_KEY(機敏資料落地加密) +``` diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..fec1b16 --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,42 @@ +# 巡樓資料服務:Mongo + Redis +# 只綁 127.0.0.1,給同主機上以 systemd 跑的 Go gateway / worker 連線。 +# 啟動:docker compose -f infra/docker-compose.yml --env-file infra/.env up -d +name: haixun-infra + +services: + mongo: + image: mongo:7 + restart: unless-stopped + ports: + - "127.0.0.1:${MONGO_PORT:-27017}:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-haixun} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD is required} + MONGO_INITDB_DATABASE: ${MONGO_DATABASE:-haixun} + volumes: + - mongo_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + + redis: + image: redis:7 + restart: unless-stopped + command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?REDIS_PASSWORD is required}", "--appendonly", "yes"] + ports: + - "127.0.0.1:${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + mongo_data: + redis_data: diff --git a/infra/etc/haixun.env.example b/infra/etc/haixun.env.example new file mode 100644 index 0000000..903366c --- /dev/null +++ b/infra/etc/haixun.env.example @@ -0,0 +1,23 @@ +# 部署到目標主機的 /opt/haixun/etc/haixun.env(chmod 600,不要 commit 實值) +# gateway.prod.yaml / gateway.worker.yaml 用 ${VAR} 讀取這些值(go-zero conf.UseEnv)。 + +# Mongo(含 docker compose 設定的帳密;authSource=admin) +HAIXUN_MONGO_URI=mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin +HAIXUN_MONGO_DB=haixun + +# Redis +HAIXUN_REDIS_ADDR=127.0.0.1:6379 +HAIXUN_REDIS_PASSWORD=change-me-redis-pass + +# JWT secret(請用 openssl rand -base64 48 產生,兩把不同) +HAIXUN_JWT_ACCESS_SECRET=replace-with-strong-random +HAIXUN_JWT_REFRESH_SECRET=replace-with-another-strong-random + +# 內部 worker secret(gateway 與 node worker 必須一致) +HAIXUN_WORKER_SECRET=replace-with-strong-random + +# 機敏資料落地加密金鑰(base64 編碼的 32 bytes;openssl rand -base64 32) +HAIXUN_SECRETS_KEY=replace-with-base64-32-bytes + +# Node worker 連線設定 +HAIXUN_BACKEND_URL=http://127.0.0.1:8890 diff --git a/infra/nginx/haixun.conf b/infra/nginx/haixun.conf new file mode 100644 index 0000000..69db9e0 --- /dev/null +++ b/infra/nginx/haixun.conf @@ -0,0 +1,56 @@ +# 巡樓 Console nginx 設定 +# 安裝:cp infra/nginx/haixun.conf /etc/nginx/conf.d/haixun.conf && nginx -t && systemctl reload nginx +# 前端靜態檔部署在 /var/www/haixun(make install 會放 frontend/dist 內容)。 +# /api 反向代理到本機 systemd 跑的 Go gateway (127.0.0.1:8890),含 SSE 串流設定。 + +upstream haixun_gateway { + server 127.0.0.1:8890; + keepalive 32; +} + +server { + listen 80; + listen [::]:80; + server_name _; + + root /var/www/haixun; + index index.html; + + # 安全標頭 + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-XSS-Protection "0" always; + + # 靜態資源快取(vite build 帶 hash 檔名) + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # API 反向代理(一般 JSON 與 SSE 共用) + location /api/ { + proxy_pass http://haixun_gateway; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Authorization(provider token)與 X-Member-Authorization(會員 JWT)由 nginx 預設轉發; + # 以下確保 SSE 串流不被緩衝、長連線不被提前中斷。 + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + chunked_transfer_encoding off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + # SPA:所有非檔案路徑都回 index.html,交給 react-router + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/infra/systemd/haixun-gateway.service b/infra/systemd/haixun-gateway.service new file mode 100644 index 0000000..9030f3f --- /dev/null +++ b/infra/systemd/haixun-gateway.service @@ -0,0 +1,25 @@ +[Unit] +Description=Haixun Gateway API (go-zero) +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=simple +User=haixun +Group=haixun +WorkingDirectory=/opt/haixun +# secrets(JWT / Mongo URI / Redis 密碼 / worker secret / 加密金鑰)放這裡,不進 repo +EnvironmentFile=/opt/haixun/etc/haixun.env +ExecStart=/opt/haixun/bin/gateway -f /opt/haixun/etc/gateway.prod.yaml +Restart=always +RestartSec=5 +LimitNOFILE=65535 + +# 加固 +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/infra/systemd/haixun-node-worker.service b/infra/systemd/haixun-node-worker.service new file mode 100644 index 0000000..7221767 --- /dev/null +++ b/infra/systemd/haixun-node-worker.service @@ -0,0 +1,23 @@ +[Unit] +Description=Haixun Node Playwright Worker (style-8d) +After=network-online.target haixun-gateway.service +Wants=network-online.target + +[Service] +Type=simple +User=haixun +Group=haixun +WorkingDirectory=/opt/haixun/node-worker +# 至少需要 HAIXUN_BACKEND_URL 與 HAIXUN_WORKER_SECRET(與 gateway 的 InternalWorker.Secret 一致) +EnvironmentFile=/opt/haixun/etc/haixun.env +ExecStart=/usr/bin/npx tsx style-8d-worker.ts +Restart=always +RestartSec=10 +LimitNOFILE=65535 + +NoNewPrivileges=true +ProtectSystem=full +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/infra/systemd/haixun-worker.service b/infra/systemd/haixun-worker.service new file mode 100644 index 0000000..df7a3e6 --- /dev/null +++ b/infra/systemd/haixun-worker.service @@ -0,0 +1,23 @@ +[Unit] +Description=Haixun Go Job Worker +After=network-online.target docker.service haixun-gateway.service +Wants=network-online.target + +[Service] +Type=simple +User=haixun +Group=haixun +WorkingDirectory=/opt/haixun +EnvironmentFile=/opt/haixun/etc/haixun.env +ExecStart=/opt/haixun/bin/worker -f /opt/haixun/etc/gateway.worker.prod.yaml +Restart=always +RestartSec=5 +LimitNOFILE=65535 + +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target