Compare commits

..

No commits in common. "fix/bug" and "main" have entirely different histories.

837 changed files with 1683 additions and 1836 deletions

BIN
.DS_Store vendored

Binary file not shown.

15
.gitignore vendored
View File

@ -1,15 +0,0 @@
# --- secrets / env不要 commit 實值)---
infra/.env
infra/etc/haixun.env
/opt/haixun/
# --- build 產物 ---
backend/bin/
frontend/dist/
# --- 依賴 ---
node_modules/
# --- 其他 ---
*.log
.DS_Store

287
Makefile
View File

@ -1,229 +1,98 @@
# 巡樓 monorepo Makefile GO ?= go
# 兩種模式: GOFMT ?= gofmt
# dev - 本機開發docker 起 Mongo/Redisgo run / vite dev GOCTL ?= goctl
# prod - 建置產物(前端 dist + linux Go binary並可部署成 systemd 服務 + nginx GO_ZERO_STYLE := go_zero
# API_ENTRY := ./generate/api/gateway.api
# 常用: GOFILES := $(shell find . -name '*.go')
# make dev-infra # 起本機 Mongo/Redis
# make dev-backend # 跑 gateway (:8890)
# make dev-frontend # 跑前端 (:5173proxy 到 :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 .DEFAULT_GOAL := help
.PHONY: help
help: ## 顯示可用指令 help: ## 顯示可用指令
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ @echo "Haixun Backend"
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
# ============================================================
# DEV
# ============================================================
$(INFRA_DIR)/.env:
@test -f $(INFRA_DIR)/.env || (cp $(INFRA_DIR)/.env.example $(INFRA_DIR)/.env && echo "已從 .env.example 建立 $(INFRA_DIR)/.env請視需要修改密碼")
.PHONY: dev-infra
dev-infra: $(INFRA_DIR)/.env ## [dev] 起本機 Mongo + Redis (docker)
$(COMPOSE) up -d
$(COMPOSE) ps
.PHONY: dev-infra-down
dev-infra-down: ## [dev] 停掉本機 Mongo + Redis
$(COMPOSE) down
.PHONY: dev-backend
dev-backend: ## [dev] 跑 gateway API (:8890)
cd $(BACKEND_DIR) && go run . -f etc/gateway.yaml
.PHONY: dev-worker
dev-worker: ## [dev] 跑 Go job worker (:8891)
cd $(BACKEND_DIR) && go run ./cmd/worker -f etc/gateway.worker.yaml
.PHONY: dev-node-worker
dev-node-worker: ## [dev] 跑 Node playwright worker (style-8d)
cd $(BACKEND_DIR)/worker && npm install && \
HAIXUN_WORKER_SECRET=$(DEV_WORKER_SECRET) HAIXUN_BACKEND_URL=$(DEV_BACKEND_URL) npm run style-8d
.PHONY: dev-frontend
dev-frontend: ## [dev] 跑前端 dev server (:5173)
cd $(FRONTEND_DIR) && npm install && npm run dev
.PHONY: dev-init
dev-init: ## [dev] 初始化 DB索引/權限)並建立 admin 帳號(可用 INIT_ADMIN_EMAIL / INIT_ADMIN_PASSWORD 覆寫)
cd $(BACKEND_DIR) && go run ./cmd/tool init -f etc/gateway.yaml
.PHONY: dev
dev: ## [dev] 顯示本機開發要開的終端
@echo "本機開發請分開幾個終端執行:"
@echo " 1) make dev-infra # Mongo/Redis"
@echo " 2) make dev-backend # gateway :8890"
@echo " 3) make dev-worker # go worker可選"
@echo " 4) make dev-node-worker # node worker需 style-8d 時)"
@echo " 5) make dev-frontend # 前端 :5173"
# ============================================================
# 安裝依賴(新機器 clone 後執行一次)
# ============================================================
.PHONY: bootstrap-sys
bootstrap-sys: ## [需 sudo] 安裝系統依賴Go, Node.js, Docker, Nginx— Ubuntu 專用
@echo "=== 安裝系統套件: nginx, curl, gnupg ==="
sudo apt-get update -qq
sudo apt-get install -y -qq nginx curl gnupg ca-certificates
@echo ""
@echo "=== 安裝 Go 1.22+ ==="
@GO_VER=$$(curl -sL https://go.dev/VERSION?m=text 2>/dev/null | head -1 || echo "go1.22"); \
echo "下載 $$GO_VER.linux-amd64.tar.gz"; \
curl -sL "https://go.dev/dl/$$GO_VER.linux-amd64.tar.gz" -o /tmp/go.tar.gz && \
sudo rm -rf /usr/local/go && \
sudo tar -C /usr/local -xzf /tmp/go.tar.gz && \
rm /tmp/go.tar.gz; \
echo 'export PATH=/usr/local/go/bin:$$PATH' | sudo tee /etc/profile.d/go.sh
@echo ""
@echo "=== 安裝 Node.js LTS ==="
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y -qq nodejs
@echo ""
@echo "=== 安裝 Docker Engine + Compose ==="
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $(USER) 2>/dev/null || true
@echo ""
@echo "=== 安裝 Nginx ==="
sudo apt-get install -y -qq nginx
@echo ""
@echo "✓ 系統依賴安裝完成。"
@echo " 登出再登入後Go/Node/Docker 即可使用group 變更生效)。"
@echo " 接著執行 make bootstrap 安裝專案層依賴。"
@echo "" @echo ""
@grep -E '^[a-zA-Z0-9_-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "}; {printf " make %-12s %s\n", $$1, $$2}'
.PHONY: bootstrap tools: ## 安裝 goctl / goimports
bootstrap: ## 安裝專案層依賴Go modules + npm + Playwright需先執行 make bootstrap-sys @command -v $(GOCTL) >/dev/null 2>&1 || (echo ">> installing goctl" && $(GO) install github.com/zeromicro/go-zero/tools/goctl@latest)
@echo "=== [1/4] Go modules ===" @command -v goimports >/dev/null 2>&1 || (echo ">> installing goimports" && $(GO) install golang.org/x/tools/cmd/goimports@latest)
cd $(BACKEND_DIR) && go mod tidy
@echo ""
@echo "=== [2/4] 前端 npm ==="
cd $(FRONTEND_DIR) && npm install
@echo ""
@echo "=== [3/4] Node worker npm ==="
cd $(BACKEND_DIR)/worker && npm install
@echo ""
@echo "=== [4/4] Playwright 瀏覽器chromium+ 系統依賴 ==="
cd $(BACKEND_DIR)/worker && sudo npx playwright install --with-deps chromium
@echo ""
@echo "✓ 全裝完成。後續步驟:"
@echo " 1) make dev-infra # 起 Mongo/Redis (Docker)"
@echo " 2) make dev-init # 初始化 DB + 建立 admin"
@echo " 3) make dev-backend # 啟動 gateway (:8890)"
@echo " 4) sudo make install # 正式部署(含 systemd + nginx"
# ============================================================ gen-api: tools ## 由 .api 生成 handler / logic / types
# BUILD (prod 產物) $(GOCTL) api go -api $(API_ENTRY) -dir . -style $(GO_ZERO_STYLE) -home generate/goctl
# ============================================================
.PHONY: build fmt: ## gofmt + goimports
build: build-frontend build-backend ## [prod] 建置前端 dist 與 Go binary $(GOFMT) -s -w $(GOFILES)
@command -v goimports >/dev/null 2>&1 && goimports -w . || true
.PHONY: build-frontend test: ## 執行測試
build-frontend: ## [prod] 前端靜態建置 (tsc + vite) -> frontend/dist $(GO) test ./...
cd $(FRONTEND_DIR) && npm ci && npm run build
.PHONY: build-backend run: ## 啟動 API前景
build-backend: ## [prod] 交叉編譯 gateway + worker -> backend/bin (linux) $(GO) run ./gateway.go -f etc/gateway.yaml
cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \
go build -trimpath -ldflags "-s -w" -o bin/gateway .
cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \
go build -trimpath -ldflags "-s -w" -o bin/worker ./cmd/worker
cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \
go build -trimpath -ldflags "-s -w" -o bin/tool ./cmd/tool
@echo "binary 已輸出到 $(BIN_DIR)/gateway / worker / tool"
# ============================================================ dev-all: ## 一鍵啟動 Mongo/Redis + API + 前端 + 8D worker背景
# PROD (部署) bash scripts/start-all.sh
# ============================================================
.PHONY: prod-infra stop-all: ## 一鍵停止全部開發服務
prod-infra: $(INFRA_DIR)/.env ## [prod] 起 Mongo + Redis (docker背景) bash scripts/stop-all.sh
$(COMPOSE) up -d
.PHONY: prod-infra-down restart-all: ## 一鍵重啟全部開發服務
prod-infra-down: ## [prod] 停掉 Mongo + Redis bash scripts/restart-all.sh
$(COMPOSE) down
.PHONY: prod-init status-all: ## 查看全部開發服務狀態
prod-init: ## [prod] 目標主機初始化 DB + 建立 admin讀 /opt/haixun/etc/haixun.env可加 INIT_ADMIN_EMAIL/PASSWORD bash scripts/status-all.sh
@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 stop: stop-all ## 同 stop-all
install: ## [prod] 安裝 binary/前端/設定/systemd/nginx需 root在目標主機執行
@test -f $(BIN_DIR)/gateway || (echo "缺少 $(BIN_DIR)/gateway請先在能 build 的機器執行 make build" && exit 1)
@test -d $(FRONTEND_DIR)/dist || (echo "缺少 $(FRONTEND_DIR)/dist請先 make build-frontend" && exit 1)
id haixun >/dev/null 2>&1 || useradd --system --home $(DEPLOY_ROOT) --shell /usr/sbin/nologin haixun
install -d $(DEPLOY_ROOT)/bin $(DEPLOY_ROOT)/etc $(DEPLOY_ROOT)/node-worker $(WEB_ROOT)
install -m 0755 $(BIN_DIR)/gateway $(DEPLOY_ROOT)/bin/gateway
install -m 0755 $(BIN_DIR)/worker $(DEPLOY_ROOT)/bin/worker
install -m 0755 $(BIN_DIR)/tool $(DEPLOY_ROOT)/bin/tool
install -m 0644 $(BACKEND_DIR)/etc/gateway.prod.yaml $(DEPLOY_ROOT)/etc/gateway.prod.yaml
install -m 0644 $(BACKEND_DIR)/etc/gateway.worker.prod.yaml $(DEPLOY_ROOT)/etc/gateway.worker.prod.yaml
rm -rf $(WEB_ROOT)/* && cp -r $(FRONTEND_DIR)/dist/* $(WEB_ROOT)/
cp -r $(BACKEND_DIR)/worker/* $(DEPLOY_ROOT)/node-worker/
cd $(DEPLOY_ROOT)/node-worker && npm ci && npx playwright install --with-deps chromium
install -m 0644 $(INFRA_DIR)/systemd/haixun-gateway.service /etc/systemd/system/haixun-gateway.service
install -m 0644 $(INFRA_DIR)/systemd/haixun-worker.service /etc/systemd/system/haixun-worker.service
install -m 0644 $(INFRA_DIR)/systemd/haixun-node-worker.service /etc/systemd/system/haixun-node-worker.service
install -m 0644 $(INFRA_DIR)/nginx/haixun.conf /etc/nginx/conf.d/haixun.conf
chown -R haixun:haixun $(DEPLOY_ROOT) $(WEB_ROOT)
@echo "----"
@echo "接著(只做一次)建立 secret 檔:"
@echo " cp $(INFRA_DIR)/etc/haixun.env.example $(DEPLOY_ROOT)/etc/haixun.env && chmod 600 $(DEPLOY_ROOT)/etc/haixun.env && sudoedit $(DEPLOY_ROOT)/etc/haixun.env"
@echo "再啟用服務:"
@echo " systemctl daemon-reload && systemctl enable --now haixun-gateway haixun-worker haixun-node-worker"
@echo " nginx -t && systemctl reload nginx"
# ============================================================ restart: restart-all ## 同 restart-all
# 驗證 / 維護
# ============================================================
.PHONY: tidy dev-8d: ## 一鍵啟動 API + Node 8D worker前景Ctrl+C 結束)
tidy: ## go mod tidy bash scripts/dev-with-style-8d.sh
cd $(BACKEND_DIR) && go mod tidy
.PHONY: fmt CONFIG ?= etc/gateway.yaml
fmt: ## gofmt 後端 INIT_TENANT ?= default
cd $(BACKEND_DIR) && gofmt -w . INIT_EMAIL ?= admin@30cm.net
INIT_PASSWORD ?= Fafafa54088
.PHONY: test tool-init: ## 初始化 Mongo indexes、預設權限與 admin 帳號
test: ## 跑後端測試 $(GO) run ./cmd/tool init -f $(CONFIG) -tenant $(INIT_TENANT) -email $(INIT_EMAIL) -password '$(INIT_PASSWORD)'
cd $(BACKEND_DIR) && go test ./...
.PHONY: verify tool: ## 執行 cmd/toolmake tool ARGS="init -f etc/gateway.yaml"
verify: ## 後端 build/test + 前端 build + compose 語法 $(GO) run ./cmd/tool $(ARGS)
cd $(BACKEND_DIR) && go build ./... && go test ./...
cd $(FRONTEND_DIR) && npm ci && npm run build web-install: ## 安裝前端依賴
$(COMPOSE) config >/dev/null && echo "docker compose config OK" cd web && npm install
web-dev: web-install ## 啟動前端 dev serverproxy 到 :8890
cd web && npm run dev
extension-pack: ## 打包 Chrome 擴充為 web/public/downloads/*.zip
bash scripts/package-extension.sh
web-build: web-install extension-pack ## 建置前端靜態檔
cd web && npm run build
node-worker-style-8d: ## 啟動 Node 8D 爬蟲 worker
cd .. && npm run worker:style-8d
check: fmt test ## 格式化並測試
prod: ## 一鍵啟動 production DockerAPI + Web + workers分身數見 deploy/.env
bash scripts/prod-up.sh
prod-update: ## 只重建/重啟 API+Web+Workersmongo/redis 不重啟,資料留在 volume
bash scripts/prod-update.sh
prod-deps: ## 只啟動 mongo+redisnamed volume 持久化)
bash scripts/prod-deps.sh
prod-down: ## 停止 stack不刪 volumeMongo/Redis 資料保留)
bash scripts/prod-down.sh
prod-wipe-data: ## 停止並刪除 mongo/redis volume危險需輸入 yes
bash scripts/prod-wipe-data.sh
prod-logs: ## 追蹤 production logs可傳 service 名make prod-logs ARGS=api
bash scripts/prod-logs.sh $(ARGS)
prod-build: web-build ## 建置靜態前端 + production images不啟動
cd deploy && docker compose -f docker-compose.prod.yml build

BIN
backend/.DS_Store vendored

Binary file not shown.

View File

@ -1,41 +0,0 @@
Name: haixun-backend
Host: 0.0.0.0
Port: 8890
Timeout: 120000
# 連線字串與所有 secret 都從環境變數注入systemd EnvironmentFile=/opt/haixun/etc/haixun.env
# go-zero 以 conf.UseEnv() + os.ExpandEnv 展開 ${VAR};未設定的變數會展開為空字串並讓服務 fail fast。
Mongo:
URI: ${HAIXUN_MONGO_URI}
Database: ${HAIXUN_MONGO_DB}
TimeoutSeconds: 10
Redis:
Addr: ${HAIXUN_REDIS_ADDR}
Password: ${HAIXUN_REDIS_PASSWORD}
DB: 0
Auth:
AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET}
RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET}
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: false
Secrets:
EncryptionKey: ${HAIXUN_SECRETS_KEY}
InternalWorker:
Secret: ${HAIXUN_WORKER_SECRET}
JobWorker:
Enabled: false
WorkerType: go
JobScheduler:
Enabled: true
IntervalSeconds: 60
JobReaper:
Enabled: true
IntervalSeconds: 30

View File

@ -1,43 +0,0 @@
Name: haixun-backend
Host: 0.0.0.0
Port: 8890
Timeout: 120000
# 本機開發設定。預設搭配 `make dev-infra`infra/docker-compose.yml跑的 Mongo/Redis
# 帳密對應 infra/.env.example 的預設值。若你改了 .env 密碼,這裡也要同步。
Mongo:
URI: mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin
Database: haixun
TimeoutSeconds: 10
Redis:
Addr: 127.0.0.1:6379
Password: change-me-redis-pass
DB: 0
Auth:
AccessSecret: haixun-dev-access-secret-change-me
RefreshSecret: haixun-dev-refresh-secret-change-me
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
# 僅本機開發開啟:允許用 X-Tenant-ID / X-UID header 模擬登入。正式環境必須為 false。
DevHeaderFallback: true
Secrets:
# 留空 = 不加密(本機開發方便)。正式環境用 ${HAIXUN_SECRETS_KEY}。
EncryptionKey: ""
InternalWorker:
Secret: haixun-dev-worker-secret
JobWorker:
Enabled: true
WorkerType: go
JobScheduler:
Enabled: true
IntervalSeconds: 60
JobReaper:
Enabled: true
IntervalSeconds: 30

View File

@ -1,99 +0,0 @@
// 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
}

View File

@ -1,41 +0,0 @@
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
}

View File

@ -1,593 +0,0 @@
{
"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"
}
}
}
}

View File

@ -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@30cm.net"), "bootstrap admin email") email := fs.String("email", envOr("INIT_ADMIN_EMAIL", "admin@haixun.local"), "bootstrap admin email")
password := fs.String("password", envOr("INIT_ADMIN_PASSWORD", "Fafafa54088"), "bootstrap admin password") password := fs.String("password", envOr("INIT_ADMIN_PASSWORD", "Admin-Pass-1!"), "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.UseEnv()) conf.MustLoad(*configFile, &cfg)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() defer cancel()

View File

@ -20,7 +20,7 @@ func main() {
flag.Parse() flag.Parse()
var c config.Config var c config.Config
conf.MustLoad(*configFile, &c, conf.UseEnv()) conf.MustLoad(*configFile, &c)
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)

40
deploy/.env Normal file
View File

@ -0,0 +1,40 @@
# 複製為 deploy/.env 後再啟動cp deploy/.env.example deploy/.env
# ── 對外埠 ──
HAIXUN_WEB_PORT=8080
# ── 前端打包模式 ──
# static = 本機 make web-build 後 nginx 只 COPY dist預設最快
# docker = 在 Docker 內跑 npm build需改 compose 用 Dockerfile.web
# HAIXUN_WEB_BUILD_MODE=static
# ── Worker 分身數make prod 會帶入 docker compose --scale──
GO_WORKER_REPLICAS=5
NODE_STYLE8D_WORKER_REPLICAS=5
# ── Mongo / Redis容器內預設通常不用改──
# 資料存在 Docker named volumehaixun-prod_mongo_data、haixun-prod_redis_data
# prod-down 不會刪 volume重啟 container 資料仍在。
# 只改版程式make prod-update不碰 mongo/redis
HAIXUN_MONGO_URI=mongodb://mongo:27017
HAIXUN_MONGO_DATABASE=haixun
HAIXUN_REDIS_ADDR=redis:6379
# ── 安全金鑰(正式環境務必更換)──
HAIXUN_AUTH_ACCESS_SECRET=change-me-access-secret
HAIXUN_AUTH_REFRESH_SECRET=change-me-refresh-secret
HAIXUN_WORKER_SECRET=change-me-worker-secret
# ── 首次初始化管理員make prod 會自動跑 init已存在則跳過建立──
INIT_TENANT_ID=default
INIT_ADMIN_EMAIL=admin@30cm.net
INIT_ADMIN_PASSWORD=Fafafa54088
# ── Node 8D worker 選項 ──
# HAIXUN_NODE_WORKER_ID=custom-node-worker-1
# HAIXUN_WORKER_POLL_MS=3000
# ── 略過自動 init ──
# 預設:若 Mongo 已有 members 會自動跳過 init。
# 強制重跑 initPROD_FORCE_INIT=1 make prod
# HAIXUN_SKIP_INIT=1

40
deploy/.env.example Normal file
View File

@ -0,0 +1,40 @@
# 複製為 deploy/.env 後再啟動cp deploy/.env.example deploy/.env
# ── 對外埠 ──
HAIXUN_WEB_PORT=8080
# ── 前端打包模式 ──
# static = 本機 make web-build 後 nginx 只 COPY dist預設最快
# docker = 在 Docker 內跑 npm build需改 compose 用 Dockerfile.web
# HAIXUN_WEB_BUILD_MODE=static
# ── Worker 分身數make prod 會帶入 docker compose --scale──
GO_WORKER_REPLICAS=1
NODE_STYLE8D_WORKER_REPLICAS=1
# ── Mongo / Redis容器內預設通常不用改──
# 資料存在 Docker named volumehaixun-prod_mongo_data、haixun-prod_redis_data
# prod-down 不會刪 volume重啟 container 資料仍在。
# 只改版程式make prod-update不碰 mongo/redis
HAIXUN_MONGO_URI=mongodb://mongo:27017
HAIXUN_MONGO_DATABASE=haixun
HAIXUN_REDIS_ADDR=redis:6379
# ── 安全金鑰(正式環境務必更換)──
HAIXUN_AUTH_ACCESS_SECRET=change-me-access-secret
HAIXUN_AUTH_REFRESH_SECRET=change-me-refresh-secret
HAIXUN_WORKER_SECRET=change-me-worker-secret
# ── 首次初始化管理員make prod 會自動跑 init已存在則跳過建立──
INIT_TENANT_ID=default
INIT_ADMIN_EMAIL=admin@haixun.local
INIT_ADMIN_PASSWORD=Admin-Pass-1!
# ── Node 8D worker 選項 ──
# HAIXUN_NODE_WORKER_ID=custom-node-worker-1
# HAIXUN_WORKER_POLL_MS=3000
# ── 略過自動 init ──
# 預設:若 Mongo 已有 members 會自動跳過 init。
# 強制重跑 initPROD_FORCE_INIT=1 make prod
# HAIXUN_SKIP_INIT=1

22
deploy/Dockerfile.api Normal file
View File

@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1
FROM golang:1.22-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/haixun-api .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/haixun-worker ./cmd/worker
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/haixun-tool ./cmd/tool
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates gettext-base curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /out/haixun-api /out/haixun-worker /out/haixun-tool /app/
COPY deploy/config/gateway.runtime.yaml.tpl deploy/config/gateway.worker.runtime.yaml.tpl /app/deploy/config/
COPY deploy/docker/entrypoint-api.sh deploy/docker/entrypoint-worker.sh deploy/docker/entrypoint-init.sh /app/deploy/docker/
RUN chmod +x /app/deploy/docker/entrypoint-api.sh /app/deploy/docker/entrypoint-worker.sh /app/deploy/docker/entrypoint-init.sh
EXPOSE 8890
ENTRYPOINT ["/app/deploy/docker/entrypoint-api.sh"]

View File

@ -0,0 +1,9 @@
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/playwright:v1.49.1-noble AS base
WORKDIR /app
COPY worker/package.json ./
RUN npm install
COPY worker/ ./
ENV NODE_ENV=production
CMD ["npx", "tsx", "style-8d-worker.ts"]

14
deploy/Dockerfile.web Normal file
View File

@ -0,0 +1,14 @@
# syntax=docker/dockerfile:1
# 備用:無本機 Node 時在 Docker 內編譯。預設請用 Dockerfile.web.static + make web-build。
FROM node:22-bookworm AS web-builder
WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM nginx:1.27-alpine
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=web-builder /src/web/dist /usr/share/nginx/html
EXPOSE 80

View File

@ -0,0 +1,7 @@
# syntax=docker/dockerfile:1
# 本機先執行 make web-build再打包純靜態檔 + nginx無 Node 編譯,建置最快)
FROM nginx:1.27-alpine
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY web/dist /usr/share/nginx/html
EXPOSE 80

64
deploy/README.md Normal file
View File

@ -0,0 +1,64 @@
# 本機依賴Docker Compose
Gateway 啟用 **Notification** / **Member OTP** 需要:
| 服務 | 用途 | 預設埠 |
|------|------|--------|
| **MongoDB** | `notifications`、`notification_dlq` collections | 27017 |
| **Redis** | 冪等、配額、異步重試佇列、member OTP challenge | 6379 |
| MailHog選用 | 本機 SMTP 測試 | 1025 / 8025 |
| OpenLDAP`make ldap-up` / `make k6-up` | ZITADEL LDAP IdP 本機目錄 | 389 |
| ZITADEL`make k6-up` | OIDC / Social / LDAP 登入 | 8080 |
Mongo **不需要**事先手動建 collection應用程式寫入時會自動建立。索引由 init script 或 `make mongo-index` 建立。
## 快速開始
```bash
# 1. 啟動 Mongo + Redis
make deps-up
# 2.(選用)含 MailHog
make deps-up-smtp
# 3. 確認索引(首次 docker volume 通常已由 init 建立;可再跑一次保險)
make mongo-index
# 4. 啟動 Gateway使用 etc/gateway.dev.yaml
make run-dev
```
## Mongo collections
| Collection | 模組 | 說明 |
|------------|------|------|
| `notifications` | notification | 發送紀錄、冪等 |
| `notification_dlq` | notification | 超過 MaxRetry 的死信 |
索引定義見 [`deploy/mongo/init/01-gateway-indexes.js`](mongo/init/01-gateway-indexes.js),與 Go 的 `Index20260520001UP` 一致。
## 常用指令
```bash
make deps-up # docker compose up -d mongo redis
make deps-up-smtp # 再加上 mailhogprofile smtp
make ldap-up # 只起 OpenLDAPprofile ldap
make k6-up # 全棧含 OpenLDAP + ZITADEL見 deploy/zitadel、deploy/openldap README
make ldap-test # 確認 LDAP 測試帳號 alice/bob
make deps-down # 停止並移除容器(保留 volume
make deps-down-v # 停止並刪除 volume會清掉 Mongo 資料)
make deps-logs # 查看 log
make mongo-index # 手動建立/補齊索引
```
LDAP 本機測試:[deploy/openldap/README.md](openldap/README.md)
## 連線設定
設定說明:[`etc/README.md`](../etc/README.md)
| 檔案 | 用途 |
|------|------|
| [`etc/gateway.yaml`](../etc/gateway.yaml) | 預設,無需 Docker |
| [`etc/gateway.dev.example.yaml`](../etc/gateway.dev.example.yaml) | 範例(可提交) |
| `etc/gateway.dev.yaml` | 本機專用(**勿提交**,見 `.gitignore` |

View File

@ -0,0 +1,35 @@
Name: haixun-backend
Host: 0.0.0.0
Port: 8890
Timeout: 120000
Mongo:
URI: ${HAIXUN_MONGO_URI}
Database: ${HAIXUN_MONGO_DATABASE}
TimeoutSeconds: 10
Redis:
Addr: ${HAIXUN_REDIS_ADDR}
DB: 0
Auth:
AccessSecret: ${HAIXUN_AUTH_ACCESS_SECRET}
RefreshSecret: ${HAIXUN_AUTH_REFRESH_SECRET}
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: false
InternalWorker:
Secret: ${HAIXUN_WORKER_SECRET}
JobWorker:
Enabled: false
WorkerType: go
JobScheduler:
Enabled: true
IntervalSeconds: 60
JobReaper:
Enabled: true
IntervalSeconds: 30

View File

@ -5,24 +5,20 @@ Timeout: 120000
Mongo: Mongo:
URI: ${HAIXUN_MONGO_URI} URI: ${HAIXUN_MONGO_URI}
Database: ${HAIXUN_MONGO_DB} Database: ${HAIXUN_MONGO_DATABASE}
TimeoutSeconds: 10 TimeoutSeconds: 10
Redis: Redis:
Addr: ${HAIXUN_REDIS_ADDR} Addr: ${HAIXUN_REDIS_ADDR}
Password: ${HAIXUN_REDIS_PASSWORD}
DB: 0 DB: 0
Auth: Auth:
AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET} AccessSecret: ${HAIXUN_AUTH_ACCESS_SECRET}
RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET} RefreshSecret: ${HAIXUN_AUTH_REFRESH_SECRET}
AccessExpireSeconds: 900 AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000 RefreshExpireSeconds: 2592000
DevHeaderFallback: false DevHeaderFallback: false
Secrets:
EncryptionKey: ${HAIXUN_SECRETS_KEY}
InternalWorker: InternalWorker:
Secret: ${HAIXUN_WORKER_SECRET} Secret: ${HAIXUN_WORKER_SECRET}

View File

@ -0,0 +1,110 @@
name: haixun-prod
services:
mongo:
image: mongo:7
restart: unless-stopped
environment:
MONGO_INITDB_DATABASE: haixun
# named volume重啟/改版不會清資料(只有 prod-wipe-data 或 docker volume rm 才會)
volumes:
- mongo_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 5s
timeout: 5s
retries: 12
start_period: 15s
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
# AOF + named volume重啟後 queue/lock 狀態可從磁碟恢復
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 12
api:
build:
context: ..
dockerfile: deploy/Dockerfile.api
restart: unless-stopped
env_file:
- .env
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8890/api/v1/health >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
go-worker:
build:
context: ..
dockerfile: deploy/Dockerfile.api
restart: unless-stopped
entrypoint: ["/app/deploy/docker/entrypoint-worker.sh"]
env_file:
- .env
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_healthy
api:
condition: service_healthy
node-worker-style-8d:
build:
context: ..
dockerfile: deploy/Dockerfile.node-worker
restart: unless-stopped
env_file:
- .env
environment:
HAIXUN_BACKEND_URL: http://api:8890
HAIXUN_WORKER_SECRET: ${HAIXUN_WORKER_SECRET}
HAIXUN_NODE_WORKER_ID: ${HAIXUN_NODE_WORKER_ID:-}
HAIXUN_WORKER_POLL_MS: ${HAIXUN_WORKER_POLL_MS:-3000}
depends_on:
api:
condition: service_healthy
web:
build:
context: ..
dockerfile: deploy/Dockerfile.web.static
restart: unless-stopped
ports:
- "${HAIXUN_WEB_PORT:-8080}:80"
depends_on:
api:
condition: service_healthy
init:
profiles: ["init"]
build:
context: ..
dockerfile: deploy/Dockerfile.api
entrypoint: ["/app/deploy/docker/entrypoint-init.sh"]
env_file:
- .env
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_healthy
volumes:
mongo_data:
redis_data:

37
deploy/docker-compose.yml Normal file
View File

@ -0,0 +1,37 @@
services:
mongo:
image: mongo:7
container_name: gateway-mongo
restart: unless-stopped
ports:
- "27017:27017"
environment:
MONGO_INITDB_DATABASE: gateway
volumes:
- mongo_data:/data/db
- ./mongo/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
redis:
image: redis:7-alpine
container_name: gateway-redis
restart: unless-stopped
ports:
- "6379:6379"
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
volumes:
mongo_data:
redis_data:

View File

@ -0,0 +1,15 @@
#!/bin/sh
set -eu
: "${HAIXUN_MONGO_URI:=mongodb://mongo:27017}"
: "${HAIXUN_MONGO_DATABASE:=haixun}"
: "${HAIXUN_REDIS_ADDR:=redis:6379}"
: "${HAIXUN_AUTH_ACCESS_SECRET:?HAIXUN_AUTH_ACCESS_SECRET is required}"
: "${HAIXUN_AUTH_REFRESH_SECRET:?HAIXUN_AUTH_REFRESH_SECRET is required}"
: "${HAIXUN_WORKER_SECRET:?HAIXUN_WORKER_SECRET is required}"
export HAIXUN_MONGO_URI HAIXUN_MONGO_DATABASE HAIXUN_REDIS_ADDR
export HAIXUN_AUTH_ACCESS_SECRET HAIXUN_AUTH_REFRESH_SECRET HAIXUN_WORKER_SECRET
envsubst < /app/deploy/config/gateway.runtime.yaml.tpl > /tmp/gateway.runtime.yaml
exec /app/haixun-api -f /tmp/gateway.runtime.yaml

View File

@ -0,0 +1,22 @@
#!/bin/sh
set -eu
: "${HAIXUN_MONGO_URI:=mongodb://mongo:27017}"
: "${HAIXUN_MONGO_DATABASE:=haixun}"
: "${HAIXUN_REDIS_ADDR:=redis:6379}"
: "${HAIXUN_AUTH_ACCESS_SECRET:?HAIXUN_AUTH_ACCESS_SECRET is required}"
: "${HAIXUN_AUTH_REFRESH_SECRET:?HAIXUN_AUTH_REFRESH_SECRET is required}"
: "${HAIXUN_WORKER_SECRET:?HAIXUN_WORKER_SECRET is required}"
: "${INIT_TENANT_ID:=default}"
: "${INIT_ADMIN_EMAIL:=admin@haixun.local}"
: "${INIT_ADMIN_PASSWORD:?INIT_ADMIN_PASSWORD is required}"
export HAIXUN_MONGO_URI HAIXUN_MONGO_DATABASE HAIXUN_REDIS_ADDR
export HAIXUN_AUTH_ACCESS_SECRET HAIXUN_AUTH_REFRESH_SECRET HAIXUN_WORKER_SECRET
envsubst < /app/deploy/config/gateway.runtime.yaml.tpl > /tmp/gateway.runtime.yaml
exec /app/haixun-tool init \
-f /tmp/gateway.runtime.yaml \
-tenant "$INIT_TENANT_ID" \
-email "$INIT_ADMIN_EMAIL" \
-password "$INIT_ADMIN_PASSWORD"

View File

@ -0,0 +1,15 @@
#!/bin/sh
set -eu
: "${HAIXUN_MONGO_URI:=mongodb://mongo:27017}"
: "${HAIXUN_MONGO_DATABASE:=haixun}"
: "${HAIXUN_REDIS_ADDR:=redis:6379}"
: "${HAIXUN_AUTH_ACCESS_SECRET:?HAIXUN_AUTH_ACCESS_SECRET is required}"
: "${HAIXUN_AUTH_REFRESH_SECRET:?HAIXUN_AUTH_REFRESH_SECRET is required}"
: "${HAIXUN_WORKER_SECRET:?HAIXUN_WORKER_SECRET is required}"
export HAIXUN_MONGO_URI HAIXUN_MONGO_DATABASE HAIXUN_REDIS_ADDR
export HAIXUN_AUTH_ACCESS_SECRET HAIXUN_AUTH_REFRESH_SECRET HAIXUN_WORKER_SECRET
envsubst < /app/deploy/config/gateway.worker.runtime.yaml.tpl > /tmp/gateway.worker.runtime.yaml
exec /app/haixun-worker -f /tmp/gateway.worker.runtime.yaml

View File

@ -0,0 +1,31 @@
// Gateway MongoDB 初始化(僅在 data volume 首次建立時執行)
// 與 internal/model/notification/repository/* Index20260520001UP 對齊
// 既有 volume 請執行make mongo-index
db = db.getSiblingDB('gateway');
print('Creating indexes on notifications...');
db.notifications.createIndex(
{ tenant_id: 1, kind: 1, idempotency_key: 1 },
{ unique: true, name: 'idx_notifications_tenant_kind_idempotency' }
);
db.notifications.createIndex(
{ tenant_id: 1, uid: 1, occurred_at: -1 },
{ name: 'idx_notifications_tenant_uid_occurred' }
);
db.notifications.createIndex(
{ status: 1, attempts: 1, occurred_at: 1 },
{ name: 'idx_notifications_status_attempts_occurred' }
);
print('Creating indexes on notification_dlq...');
db.notification_dlq.createIndex(
{ tenant_id: 1, occurred_at: -1 },
{ name: 'idx_notification_dlq_tenant_occurred' }
);
print('Gateway Mongo init done.');

55
deploy/nginx.conf Normal file
View File

@ -0,0 +1,55 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;
# Vite 產物:檔名含 hash可長期快取
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location /downloads/ {
add_header Cache-Control "public, max-age=86400";
try_files $uri =404;
}
location /illustrations/ {
add_header Cache-Control "public, max-age=86400";
try_files $uri =404;
}
# SPA 入口與路由:不快取,避免部署後仍載入舊版 shell
location = /index.html {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location /api/ {
proxy_pass http://api:8890;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location / {
try_files $uri $uri/ /index.html;
}
}

35
etc/gateway.prod.yaml Normal file
View File

@ -0,0 +1,35 @@
Name: haixun-backend
Host: 0.0.0.0
Port: 8890
Timeout: 120000
Mongo:
URI: mongodb://mongo:27017
Database: haixun
TimeoutSeconds: 10
Redis:
Addr: redis:6379
DB: 0
Auth:
AccessSecret: change-me-in-prod
RefreshSecret: change-me-in-prod-too
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: false
InternalWorker:
Secret: change-me-worker-secret
JobWorker:
Enabled: false
WorkerType: go
JobScheduler:
Enabled: true
IntervalSeconds: 60
JobReaper:
Enabled: true
IntervalSeconds: 30

35
etc/gateway.worker.yaml Normal file
View File

@ -0,0 +1,35 @@
Name: haixun-worker
Host: 0.0.0.0
Port: 8891
Timeout: 120000
Mongo:
URI: mongodb://mongo:27017
Database: haixun
TimeoutSeconds: 10
Redis:
Addr: redis:6379
DB: 0
Auth:
AccessSecret: change-me-in-prod
RefreshSecret: change-me-in-prod-too
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: false
InternalWorker:
Secret: change-me-worker-secret
JobWorker:
Enabled: true
WorkerType: go
JobScheduler:
Enabled: false
IntervalSeconds: 60
JobReaper:
Enabled: false
IntervalSeconds: 30

View File

@ -1,17 +1,15 @@
Name: haixun-worker Name: haixun-backend
Host: 0.0.0.0 Host: 0.0.0.0
Port: 8891 Port: 8890
Timeout: 120000 Timeout: 120000
# 本機開發 worker 設定go worker。搭配 `make dev-infra` 的 Mongo/Redis。
Mongo: Mongo:
URI: mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin URI: mongodb://127.0.0.1:27017
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:
@ -19,22 +17,16 @@ Auth:
RefreshSecret: haixun-dev-refresh-secret-change-me RefreshSecret: haixun-dev-refresh-secret-change-me
AccessExpireSeconds: 900 AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000 RefreshExpireSeconds: 2592000
DevHeaderFallback: false DevHeaderFallback: true
Secrets:
EncryptionKey: ""
InternalWorker:
Secret: haixun-dev-worker-secret
JobWorker: JobWorker:
Enabled: true Enabled: true
WorkerType: go WorkerType: go
JobScheduler: JobScheduler:
Enabled: false Enabled: true
IntervalSeconds: 60 IntervalSeconds: 60
JobReaper: JobReaper:
Enabled: false Enabled: true
IntervalSeconds: 30 IntervalSeconds: 30

View File

@ -1,50 +0,0 @@
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>
)
}
}

View File

@ -19,7 +19,7 @@ func main() {
flag.Parse() flag.Parse()
var c config.Config var c config.Config
conf.MustLoad(*configFile, &c, conf.UseEnv()) conf.MustLoad(*configFile, &c)
server := rest.MustNewServer(c.RestConf) server := rest.MustNewServer(c.RestConf)
defer server.Stop() defer server.Stop()

View File

@ -1,20 +0,0 @@
# Infra docker compose 環境變數範本
# 複製成 infra/.env 後填入實際值;.env 不要 commit。
# --- Mongo ---
MONGO_PORT=27017
MONGO_ROOT_USER=haixun
MONGO_ROOT_PASSWORD=1qaz@WSX3edc$RFV
MONGO_DATABASE=haixun
# --- Redis ---
REDIS_PORT=6379
REDIS_PASSWORD=change-me-redis-pass
INIT_ADMIN_EMAIL=admin@30cm.net
PASSWORD=Fafafafa54088
HAIXUN_JWT_ACCESS_SECRET=1qaz@WSX3edc$RFV
HAIXUN_JWT_REFRESH_SECRET=1qaz@WSX3edc$RFV
HAIXUN_SECRETS_KEY=1qaz@WSX3edc$RFV
HAIXUN_WORKER_SECRET=1qaz@WSX3edc$RFV

View File

@ -1,103 +0,0 @@
# 巡樓部署 (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`(不進 repoyaml 用 `${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 binarybackend/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機敏資料落地加密
```

View File

@ -1,42 +0,0 @@
# 巡樓資料服務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:

View File

@ -1,23 +0,0 @@
# 部署到目標主機的 /opt/haixun/etc/haixun.envchmod 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 secretgateway 與 node worker 必須一致)
HAIXUN_WORKER_SECRET=replace-with-strong-random
# 機敏資料落地加密金鑰base64 編碼的 32 bytesopenssl rand -base64 32
HAIXUN_SECRETS_KEY=replace-with-base64-32-bytes
# Node worker 連線設定
HAIXUN_BACKEND_URL=http://127.0.0.1:8890

View File

@ -1,56 +0,0 @@
# 巡樓 Console nginx 設定
# 安裝cp infra/nginx/haixun.conf /etc/nginx/conf.d/haixun.conf && nginx -t && systemctl reload nginx
# 前端靜態檔部署在 /var/www/haixunmake 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;
# Authorizationprovider 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;
}
}

View File

@ -1,25 +0,0 @@
[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
# secretsJWT / 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

View File

@ -1,23 +0,0 @@
[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

View File

@ -1,23 +0,0 @@
[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

View File

@ -5,23 +5,12 @@ 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 {
@ -59,12 +48,6 @@ 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)
@ -86,17 +69,6 @@ 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 {

View File

@ -9,9 +9,8 @@ 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"`
} }
type JobWorkerConf struct { type JobWorkerConf struct {
@ -35,19 +34,13 @@ 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=false"` DevHeaderFallback bool `json:",default=true"`
} }
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"`
} }
@ -57,7 +50,6 @@ 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"`

Some files were not shown because too many files have changed in this diff Show More