This commit is contained in:
王性驊 2026-06-27 00:02:06 +08:00
parent 3342f7a344
commit ffd6a31d6f
51 changed files with 1762 additions and 137 deletions

BIN
.DS_Store vendored

Binary file not shown.

15
.gitignore vendored Normal file
View File

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

174
Makefile Normal file
View File

@ -0,0 +1,174 @@
# 巡樓 monorepo Makefile
# 兩種模式:
# dev - 本機開發docker 起 Mongo/Redisgo run / vite dev
# prod - 建置產物(前端 dist + linux Go binary並可部署成 systemd 服務 + nginx
#
# 常用:
# 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
.PHONY: help
help: ## 顯示可用指令
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
# ============================================================
# DEV
# ============================================================
$(INFRA_DIR)/.env:
@test -f $(INFRA_DIR)/.env || (cp $(INFRA_DIR)/.env.example $(INFRA_DIR)/.env && echo "已從 .env.example 建立 $(INFRA_DIR)/.env請視需要修改密碼")
.PHONY: dev-infra
dev-infra: $(INFRA_DIR)/.env ## [dev] 起本機 Mongo + Redis (docker)
$(COMPOSE) up -d
$(COMPOSE) ps
.PHONY: dev-infra-down
dev-infra-down: ## [dev] 停掉本機 Mongo + Redis
$(COMPOSE) down
.PHONY: dev-backend
dev-backend: ## [dev] 跑 gateway API (:8890)
cd $(BACKEND_DIR) && go run . -f etc/gateway.yaml
.PHONY: dev-worker
dev-worker: ## [dev] 跑 Go job worker (:8891)
cd $(BACKEND_DIR) && go run ./cmd/worker -f etc/gateway.worker.yaml
.PHONY: dev-node-worker
dev-node-worker: ## [dev] 跑 Node playwright worker (style-8d)
cd $(BACKEND_DIR)/worker && npm install && \
HAIXUN_WORKER_SECRET=$(DEV_WORKER_SECRET) HAIXUN_BACKEND_URL=$(DEV_BACKEND_URL) npm run style-8d
.PHONY: dev-frontend
dev-frontend: ## [dev] 跑前端 dev server (:5173)
cd $(FRONTEND_DIR) && npm install && npm run dev
.PHONY: dev-init
dev-init: ## [dev] 初始化 DB索引/權限)並建立 admin 帳號(可用 INIT_ADMIN_EMAIL / INIT_ADMIN_PASSWORD 覆寫)
cd $(BACKEND_DIR) && go run ./cmd/tool init -f etc/gateway.yaml
.PHONY: dev
dev: ## [dev] 顯示本機開發要開的終端
@echo "本機開發請分開幾個終端執行:"
@echo " 1) make dev-infra # Mongo/Redis"
@echo " 2) make dev-backend # gateway :8890"
@echo " 3) make dev-worker # go worker可選"
@echo " 4) make dev-node-worker # node worker需 style-8d 時)"
@echo " 5) make dev-frontend # 前端 :5173"
# ============================================================
# BUILD (prod 產物)
# ============================================================
.PHONY: build
build: build-frontend build-backend ## [prod] 建置前端 dist 與 Go binary
.PHONY: build-frontend
build-frontend: ## [prod] 前端靜態建置 (tsc + vite) -> frontend/dist
cd $(FRONTEND_DIR) && npm ci && npm run build
.PHONY: build-backend
build-backend: ## [prod] 交叉編譯 gateway + worker -> backend/bin (linux)
cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \
go build -trimpath -ldflags "-s -w" -o bin/gateway .
cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \
go build -trimpath -ldflags "-s -w" -o bin/worker ./cmd/worker
cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \
go build -trimpath -ldflags "-s -w" -o bin/tool ./cmd/tool
@echo "binary 已輸出到 $(BIN_DIR)/gateway / worker / tool"
# ============================================================
# PROD (部署)
# ============================================================
.PHONY: prod-infra
prod-infra: $(INFRA_DIR)/.env ## [prod] 起 Mongo + Redis (docker背景)
$(COMPOSE) up -d
.PHONY: prod-infra-down
prod-infra-down: ## [prod] 停掉 Mongo + Redis
$(COMPOSE) down
.PHONY: prod-init
prod-init: ## [prod] 目標主機初始化 DB + 建立 admin讀 /opt/haixun/etc/haixun.env可加 INIT_ADMIN_EMAIL/PASSWORD
@test -x $(DEPLOY_ROOT)/bin/tool || (echo "缺少 $(DEPLOY_ROOT)/bin/tool請先 make install" && exit 1)
set -a; . $(DEPLOY_ROOT)/etc/haixun.env; set +a; \
$(DEPLOY_ROOT)/bin/tool init -f $(DEPLOY_ROOT)/etc/gateway.prod.yaml
.PHONY: install
install: ## [prod] 安裝 binary/前端/設定/systemd/nginx需 root在目標主機執行
@test -f $(BIN_DIR)/gateway || (echo "缺少 $(BIN_DIR)/gateway請先在能 build 的機器執行 make build" && exit 1)
@test -d $(FRONTEND_DIR)/dist || (echo "缺少 $(FRONTEND_DIR)/dist請先 make build-frontend" && exit 1)
id haixun >/dev/null 2>&1 || useradd --system --home $(DEPLOY_ROOT) --shell /usr/sbin/nologin haixun
install -d $(DEPLOY_ROOT)/bin $(DEPLOY_ROOT)/etc $(DEPLOY_ROOT)/node-worker $(WEB_ROOT)
install -m 0755 $(BIN_DIR)/gateway $(DEPLOY_ROOT)/bin/gateway
install -m 0755 $(BIN_DIR)/worker $(DEPLOY_ROOT)/bin/worker
install -m 0755 $(BIN_DIR)/tool $(DEPLOY_ROOT)/bin/tool
install -m 0644 $(BACKEND_DIR)/etc/gateway.prod.yaml $(DEPLOY_ROOT)/etc/gateway.prod.yaml
install -m 0644 $(BACKEND_DIR)/etc/gateway.worker.prod.yaml $(DEPLOY_ROOT)/etc/gateway.worker.prod.yaml
rm -rf $(WEB_ROOT)/* && cp -r $(FRONTEND_DIR)/dist/* $(WEB_ROOT)/
cp -r $(BACKEND_DIR)/worker/* $(DEPLOY_ROOT)/node-worker/
cd $(DEPLOY_ROOT)/node-worker && npm ci && npx playwright install --with-deps chromium
install -m 0644 $(INFRA_DIR)/systemd/haixun-gateway.service /etc/systemd/system/haixun-gateway.service
install -m 0644 $(INFRA_DIR)/systemd/haixun-worker.service /etc/systemd/system/haixun-worker.service
install -m 0644 $(INFRA_DIR)/systemd/haixun-node-worker.service /etc/systemd/system/haixun-node-worker.service
install -m 0644 $(INFRA_DIR)/nginx/haixun.conf /etc/nginx/conf.d/haixun.conf
chown -R haixun:haixun $(DEPLOY_ROOT) $(WEB_ROOT)
@echo "----"
@echo "接著(只做一次)建立 secret 檔:"
@echo " cp $(INFRA_DIR)/etc/haixun.env.example $(DEPLOY_ROOT)/etc/haixun.env && chmod 600 $(DEPLOY_ROOT)/etc/haixun.env && sudoedit $(DEPLOY_ROOT)/etc/haixun.env"
@echo "再啟用服務:"
@echo " systemctl daemon-reload && systemctl enable --now haixun-gateway haixun-worker haixun-node-worker"
@echo " nginx -t && systemctl reload nginx"
# ============================================================
# 驗證 / 維護
# ============================================================
.PHONY: tidy
tidy: ## go mod tidy
cd $(BACKEND_DIR) && go mod tidy
.PHONY: fmt
fmt: ## gofmt 後端
cd $(BACKEND_DIR) && gofmt -w .
.PHONY: test
test: ## 跑後端測試
cd $(BACKEND_DIR) && go test ./...
.PHONY: verify
verify: ## 後端 build/test + 前端 build + compose 語法
cd $(BACKEND_DIR) && go build ./... && go test ./...
cd $(FRONTEND_DIR) && npm ci && npm run build
$(COMPOSE) config >/dev/null && echo "docker compose config OK"

BIN
backend/.DS_Store vendored

Binary file not shown.

View File

@ -36,15 +36,15 @@ func runInit(args []string) error {
fs := flag.NewFlagSet("init", flag.ExitOnError)
configFile := fs.String("f", "etc/gateway.yaml", "config file")
tenantID := fs.String("tenant", envOr("INIT_TENANT_ID", "default"), "tenant id for admin and role permissions")
email := fs.String("email", envOr("INIT_ADMIN_EMAIL", "admin@haixun.local"), "bootstrap admin email")
password := fs.String("password", envOr("INIT_ADMIN_PASSWORD", "Admin-Pass-1!"), "bootstrap admin password")
email := fs.String("email", envOr("INIT_ADMIN_EMAIL", "admin@30cm.net"), "bootstrap admin email")
password := fs.String("password", envOr("INIT_ADMIN_PASSWORD", "Fafafa54088"), "bootstrap admin password")
displayName := fs.String("display-name", envOr("INIT_ADMIN_DISPLAY_NAME", "Admin"), "bootstrap admin display name")
if err := fs.Parse(args); err != nil {
return err
}
var cfg config.Config
conf.MustLoad(*configFile, &cfg)
conf.MustLoad(*configFile, &cfg, conf.UseEnv())
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()

View File

@ -20,7 +20,7 @@ func main() {
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
conf.MustLoad(*configFile, &c, conf.UseEnv())
if !c.JobWorker.Enabled {
fmt.Fprintln(os.Stderr, "[worker] JobWorker.Enabled must be true")
os.Exit(1)

View File

@ -3,24 +3,30 @@ 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: mongodb://mongo:27017
Database: haixun
URI: ${HAIXUN_MONGO_URI}
Database: ${HAIXUN_MONGO_DB}
TimeoutSeconds: 10
Redis:
Addr: redis:6379
Addr: ${HAIXUN_REDIS_ADDR}
Password: ${HAIXUN_REDIS_PASSWORD}
DB: 0
Auth:
AccessSecret: change-me-in-prod
RefreshSecret: change-me-in-prod-too
AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET}
RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET}
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: false
Secrets:
EncryptionKey: ${HAIXUN_SECRETS_KEY}
InternalWorker:
Secret: change-me-worker-secret
Secret: ${HAIXUN_WORKER_SECRET}
JobWorker:
Enabled: false

View File

@ -0,0 +1,39 @@
Name: haixun-worker
Host: 0.0.0.0
Port: 8891
Timeout: 120000
Mongo:
URI: ${HAIXUN_MONGO_URI}
Database: ${HAIXUN_MONGO_DB}
TimeoutSeconds: 10
Redis:
Addr: ${HAIXUN_REDIS_ADDR}
Password: ${HAIXUN_REDIS_PASSWORD}
DB: 0
Auth:
AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET}
RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET}
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: false
Secrets:
EncryptionKey: ${HAIXUN_SECRETS_KEY}
InternalWorker:
Secret: ${HAIXUN_WORKER_SECRET}
JobWorker:
Enabled: true
WorkerType: go
JobScheduler:
Enabled: false
IntervalSeconds: 60
JobReaper:
Enabled: false
IntervalSeconds: 30

View File

@ -3,24 +3,29 @@ Host: 0.0.0.0
Port: 8891
Timeout: 120000
# 本機開發 worker 設定go worker。搭配 `make dev-infra` 的 Mongo/Redis。
Mongo:
URI: mongodb://mongo:27017
URI: mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin
Database: haixun
TimeoutSeconds: 10
Redis:
Addr: redis:6379
Addr: 127.0.0.1:6379
Password: change-me-redis-pass
DB: 0
Auth:
AccessSecret: change-me-in-prod
RefreshSecret: change-me-in-prod-too
AccessSecret: haixun-dev-access-secret-change-me
RefreshSecret: haixun-dev-refresh-secret-change-me
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: false
Secrets:
EncryptionKey: ""
InternalWorker:
Secret: change-me-worker-secret
Secret: haixun-dev-worker-secret
JobWorker:
Enabled: true

View File

@ -3,13 +3,16 @@ 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://127.0.0.1:27017
URI: mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin
Database: haixun
TimeoutSeconds: 10
Redis:
Addr: 127.0.0.1:6379
Password: change-me-redis-pass
DB: 0
Auth:
@ -17,8 +20,16 @@ Auth:
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

View File

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

View File

@ -5,12 +5,23 @@ import (
"fmt"
"haixun-backend/internal/config"
libcrypto "haixun-backend/internal/library/crypto"
libmongo "haixun-backend/internal/library/mongo"
brandrepo "haixun-backend/internal/model/brand/repository"
cmatrixrepo "haixun-backend/internal/model/content_matrix/repository"
copydraftrepo "haixun-backend/internal/model/copy_draft/repository"
copymissionrepo "haixun-backend/internal/model/copy_mission/repository"
jobrepo "haixun-backend/internal/model/job/repository"
kgrepo "haixun-backend/internal/model/knowledge_graph/repository"
memberrepo "haixun-backend/internal/model/member/repository"
outreachdraftrepo "haixun-backend/internal/model/outreach_draft/repository"
permissionrepo "haixun-backend/internal/model/permission/repository"
permissionuc "haixun-backend/internal/model/permission/usecase"
personarepo "haixun-backend/internal/model/persona/repository"
placementtopicrepo "haixun-backend/internal/model/placement_topic/repository"
scanpostrepo "haixun-backend/internal/model/scan_post/repository"
settingrepo "haixun-backend/internal/model/setting/repository"
threadsaccountrepo "haixun-backend/internal/model/threads_account/repository"
)
type InitOptions struct {
@ -48,6 +59,12 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport
db := mongoClient.Database()
report := &InitReport{}
// cipher 只用於資料加解密EnsureIndexes 不會用到,但 secrets repo 建構子需要它。
secretsCipher, err := libcrypto.New(cfg.Secrets.EncryptionKey)
if err != nil {
return nil, fmt.Errorf("init secrets cipher: %w", err)
}
settingRepository := settingrepo.NewMongoRepository(db)
memberRepository := memberrepo.NewMongoRepository(db)
permissionRepository := permissionrepo.NewMongoPermissionRepository(db)
@ -69,6 +86,17 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport
{"job_runs", jobRunRepository.EnsureIndexes},
{"job_schedules", jobScheduleRepository.EnsureIndexes},
{"job_events", jobEventRepository.EnsureIndexes},
{"copy_missions", copymissionrepo.NewMongoRepository(db).EnsureIndexes},
{"copy_drafts", copydraftrepo.NewMongoRepository(db).EnsureIndexes},
{"scan_posts", scanpostrepo.NewMongoRepository(db).EnsureIndexes},
{"outreach_drafts", outreachdraftrepo.NewMongoRepository(db).EnsureIndexes},
{"content_matrix", cmatrixrepo.NewMongoRepository(db).EnsureIndexes},
{"knowledge_graph", kgrepo.NewMongoRepository(db).EnsureIndexes},
{"personas", personarepo.NewMongoRepository(db).EnsureIndexes},
{"brands", brandrepo.NewMongoRepository(db).EnsureIndexes},
{"placement_topics", placementtopicrepo.NewMongoRepository(db).EnsureIndexes},
{"threads_accounts", threadsaccountrepo.NewMongoRepository(db).EnsureIndexes},
{"threads_account_secrets", threadsaccountrepo.NewSecretsMongoRepository(db, secretsCipher).EnsureIndexes},
}
for _, repo := range repos {
if err := repo.fn(ctx); err != nil {

View File

@ -10,6 +10,7 @@ type MongoConf struct {
type RedisConf struct {
Addr string `json:",optional"`
Password string `json:",optional"`
DB int `json:",optional"`
}
@ -34,13 +35,19 @@ type AuthConf struct {
RefreshSecret string `json:",optional"`
AccessExpireSeconds int64 `json:",default=900"`
RefreshExpireSeconds int64 `json:",default=2592000"`
DevHeaderFallback bool `json:",default=true"`
DevHeaderFallback bool `json:",default=false"`
}
type InternalWorkerConf struct {
Secret string `json:",optional"`
}
// SecretsConf holds the application-layer encryption key (base64 of 32 bytes)
// used to encrypt sensitive data at rest (browser session, third-party API keys).
type SecretsConf struct {
EncryptionKey string `json:",optional"`
}
type BraveConf struct {
APIKey string `json:",optional"`
}
@ -50,6 +57,7 @@ type Config struct {
Mongo MongoConf `json:",optional"`
Redis RedisConf `json:",optional"`
Auth AuthConf `json:",optional"`
Secrets SecretsConf `json:",optional"`
InternalWorker InternalWorkerConf `json:",optional"`
JobWorker JobWorkerConf `json:",optional"`
JobScheduler JobSchedulerConf `json:",optional"`

View File

@ -0,0 +1,99 @@
// Package crypto provides application-layer encryption for sensitive data at
// rest (browser session storage, third-party API keys).
//
// Ciphertext format: "enc:v1:" + base64url(nonce || ciphertext||tag).
// Values without the prefix are treated as legacy plaintext and returned as-is
// on Decrypt, so enabling encryption is backward compatible with existing data.
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"strings"
)
const cipherPrefix = "enc:v1:"
// Cipher encrypts/decrypts secret strings. When built without a key it is
// disabled and acts as a passthrough (for local dev), but Decrypt still
// transparently handles previously written ciphertext if any.
type Cipher struct {
aead cipher.AEAD
}
// New builds a Cipher from a base64-encoded 32-byte (AES-256) key.
// An empty key returns a disabled (passthrough) cipher.
func New(base64Key string) (*Cipher, error) {
base64Key = strings.TrimSpace(base64Key)
if base64Key == "" {
return &Cipher{}, nil
}
key, err := base64.StdEncoding.DecodeString(base64Key)
if err != nil {
return nil, fmt.Errorf("crypto: invalid base64 encryption key: %w", err)
}
if len(key) != 32 {
return nil, fmt.Errorf("crypto: encryption key must be 32 bytes (got %d)", len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("crypto: new cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("crypto: new gcm: %w", err)
}
return &Cipher{aead: aead}, nil
}
// Enabled reports whether a key is configured.
func (c *Cipher) Enabled() bool {
return c != nil && c.aead != nil
}
// Encrypt returns ciphertext for non-empty plaintext when enabled; otherwise
// it returns the input unchanged.
func (c *Cipher) Encrypt(plaintext string) (string, error) {
if !c.Enabled() || plaintext == "" {
return plaintext, nil
}
if strings.HasPrefix(plaintext, cipherPrefix) {
// Already encrypted; avoid double-encrypting.
return plaintext, nil
}
nonce := make([]byte, c.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", fmt.Errorf("crypto: read nonce: %w", err)
}
sealed := c.aead.Seal(nonce, nonce, []byte(plaintext), nil)
return cipherPrefix + base64.RawURLEncoding.EncodeToString(sealed), nil
}
// Decrypt reverses Encrypt. Values without the cipher prefix are returned
// unchanged (legacy plaintext), keeping the change backward compatible.
func (c *Cipher) Decrypt(value string) (string, error) {
if !strings.HasPrefix(value, cipherPrefix) {
return value, nil
}
if !c.Enabled() {
return "", errors.New("crypto: encrypted value found but no encryption key configured")
}
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(value, cipherPrefix))
if err != nil {
return "", fmt.Errorf("crypto: decode ciphertext: %w", err)
}
nonceSize := c.aead.NonceSize()
if len(raw) < nonceSize {
return "", errors.New("crypto: ciphertext too short")
}
nonce, ct := raw[:nonceSize], raw[nonceSize:]
plain, err := c.aead.Open(nil, nonce, ct, nil)
if err != nil {
return "", fmt.Errorf("crypto: decrypt: %w", err)
}
return string(plain), nil
}

View File

@ -12,6 +12,7 @@ func NewClient(conf config.RedisConf) *goredis.Client {
}
return goredis.NewClient(&goredis.Options{
Addr: conf.Addr,
Password: conf.Password,
DB: conf.DB,
})
}

View File

@ -0,0 +1,41 @@
package setting
import (
"context"
"haixun-backend/internal/library/authctx"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/svc"
)
// authorizeSettingAccess guards the generic settings API against horizontal
// privilege escalation (IDOR). The settings collection stores sensitive values
// such as AI / Brave / Exa API keys under the "user" and "account" scopes, so
// the caller must be the owner of the scope being accessed.
//
// - "user": scope_id must equal the caller uid (uid is enumerable).
// - "account": the threads account must belong to the caller.
// - other scopes (brand / persona / placement_topic / copy_mission ...):
// keyed by unguessable ObjectIDs and hold non-secret research config;
// ownership is enforced by their own feature endpoints. We still require an
// authenticated actor here.
func authorizeSettingAccess(ctx context.Context, svcCtx *svc.ServiceContext, scope, scopeID string) error {
actor, ok := authctx.ActorFromContext(ctx)
if !ok || actor.UID == "" {
return app.For(code.Auth).AuthUnauthorized("missing actor")
}
switch scope {
case "user":
if scopeID != actor.UID {
return app.For(code.Setting).AuthForbidden("cannot access another user's settings")
}
case "account":
if _, err := svcCtx.ThreadsAccount.Get(ctx, actor.TenantID, actor.UID, scopeID); err != nil {
return app.For(code.Setting).AuthForbidden("cannot access settings of an account you do not own")
}
}
return nil
}

View File

@ -17,5 +17,8 @@ func NewDeleteSettingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Del
}
func (l *DeleteSettingLogic) DeleteSetting(req *types.SettingKeyPath) error {
if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil {
return err
}
return l.svcCtx.Setting.Delete(l.ctx, req.Scope, req.ScopeID, req.Key)
}

View File

@ -17,6 +17,9 @@ func NewGetSettingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetSet
}
func (l *GetSettingLogic) GetSetting(req *types.SettingKeyPath) (*types.SettingData, error) {
if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil {
return nil, err
}
item, err := l.svcCtx.Setting.Get(l.ctx, req.Scope, req.ScopeID, req.Key)
if err != nil {
return nil, err

View File

@ -17,6 +17,9 @@ func NewListSettingsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *List
}
func (l *ListSettingsLogic) ListSettings(req *types.SettingPath) (*types.SettingListData, error) {
if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil {
return nil, err
}
items, total, page, pageSize, err := l.svcCtx.Setting.List(l.ctx, req.Scope, req.ScopeID, req.Page, req.PageSize)
if err != nil {
return nil, err

View File

@ -18,6 +18,9 @@ func NewUpsertSettingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Ups
}
func (l *UpsertSettingLogic) UpsertSetting(req *types.SettingUpsertReq) (*types.SettingData, error) {
if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil {
return nil, err
}
item, err := l.svcCtx.Setting.Upsert(l.ctx, settinguc.UpsertRequest{
Scope: req.Scope,
ScopeID: req.ScopeID,

View File

@ -1,22 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package middleware
import "net/http"
type AuthMiddleware struct {
}
func NewAuthMiddleware() *AuthMiddleware {
return &AuthMiddleware{}
}
func (m *AuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO generate middleware implement function, delete after code implementation
// Passthrough to next handler if need
next(w, r)
}
}

View File

@ -13,7 +13,14 @@ import (
)
// PermissionRBACMiddleware enforces catalog permissions for authenticated members.
// Mount after AuthJWT or MemberAuth so actor is present in context.
// It is composed AFTER AuthJWT / MemberAuth (see svc.NewServiceContext) so the
// actor is already in context.
//
// Route coverage: defaultPermissions uses trailing-* patterns
// (/api/v1/brands/*, /api/v1/personas/*, /api/v1/placement/topics/*, ...) that
// cover nested routes (copy-missions, scan-posts, knowledge-graph, etc.).
// permission.Me also falls back to the default user permission set when a tenant
// has no seeded role_permissions, so members are never locked out by a missing seed.
type PermissionRBACMiddleware struct {
members memberdomain.UseCase
permissions permissiondomain.UseCase

View File

@ -1,6 +1,7 @@
package middleware
import (
"crypto/subtle"
"net/http"
"strings"
@ -12,10 +13,10 @@ import (
const WorkerSecretHeader = "X-Worker-Secret"
// WorkerSecretMiddleware enforces X-Worker-Secret on internal worker routes when
// InternalWorker.Secret is configured. Mounted via @server(middleware: WorkerSecret)
// in generate/api/worker_internal.api. When the secret is empty it passes through,
// preserving local-dev behaviour.
// WorkerSecretMiddleware enforces X-Worker-Secret on internal worker routes.
// The secret is REQUIRED: when InternalWorker.Secret is empty the middleware
// rejects every request (fail closed) instead of passing through, so the
// internal worker endpoints can never be exposed unauthenticated.
type WorkerSecretMiddleware struct {
cfg config.InternalWorkerConf
}
@ -27,7 +28,12 @@ func NewWorkerSecretMiddleware(cfg config.InternalWorkerConf) *WorkerSecretMiddl
func (m *WorkerSecretMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
secret := strings.TrimSpace(m.cfg.Secret)
if secret != "" && r.Header.Get(WorkerSecretHeader) != secret {
if secret == "" {
response.Write(r.Context(), w, nil, app.For(code.Auth).AuthForbidden("internal worker secret is not configured"))
return
}
provided := r.Header.Get(WorkerSecretHeader)
if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 {
response.Write(r.Context(), w, nil, app.For(code.Auth).AuthUnauthorized("invalid worker secret"))
return
}

View File

@ -23,6 +23,12 @@ type tokenUseCase struct {
func NewTokenUseCase(cfg config.AuthConf, revoke domrepo.TokenRevokeStore) domusecase.TokenUseCase {
cfg = normalizeConfig(cfg)
if cfg.AccessSecret == "" || cfg.RefreshSecret == "" {
// Fail fast: never fall back to a known/hardcoded secret, otherwise tokens
// could be forged. Provide secrets via env (HAIXUN_JWT_ACCESS_SECRET /
// HAIXUN_JWT_REFRESH_SECRET) or the dev config file.
panic("auth: AccessSecret and RefreshSecret must be configured")
}
return &tokenUseCase{cfg: cfg, revoke: revoke}
}
@ -210,12 +216,6 @@ func normalizeConfig(cfg config.AuthConf) config.AuthConf {
if cfg.RefreshExpireSeconds <= 0 {
cfg.RefreshExpireSeconds = 2592000
}
if cfg.AccessSecret == "" {
cfg.AccessSecret = "haixun-dev-access-secret-change-me"
}
if cfg.RefreshSecret == "" {
cfg.RefreshSecret = "haixun-dev-refresh-secret-change-me"
}
return cfg
}

View File

@ -4,6 +4,7 @@ import (
"context"
"strings"
"haixun-backend/internal/library/crypto"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
libkg "haixun-backend/internal/library/knowledge"
@ -47,10 +48,11 @@ type UseCase interface {
type placementUseCase struct {
settings settingdomain.UseCase
cipher *crypto.Cipher
}
func NewUseCase(settings settingdomain.UseCase) UseCase {
return &placementUseCase{settings: settings}
func NewUseCase(settings settingdomain.UseCase, cipher *crypto.Cipher) UseCase {
return &placementUseCase{settings: settings, cipher: cipher}
}
func (u *placementUseCase) Get(ctx context.Context, tenantID, ownerUID string) (*Settings, error) {
@ -107,15 +109,49 @@ func (u *placementUseCase) load(ctx context.Context, ownerUID string) (storedSet
}
return defaults, err
}
return mergeSettings(defaults, setting.Value), nil
merged := mergeSettings(defaults, setting.Value)
if u.cipher != nil {
if merged.BraveAPIKey, err = u.decryptKey(merged.BraveAPIKey); err != nil {
return defaults, err
}
if merged.ExaAPIKey, err = u.decryptKey(merged.ExaAPIKey); err != nil {
return defaults, err
}
}
return merged, nil
}
func (u *placementUseCase) decryptKey(value string) (string, error) {
if value == "" {
return "", nil
}
plain, err := u.cipher.Decrypt(value)
if err != nil {
return "", err
}
return strings.TrimSpace(plain), nil
}
func (u *placementUseCase) save(ctx context.Context, ownerUID string, value storedSettings) error {
payload := value.toMap()
if u.cipher != nil {
for _, key := range []string{"brave_api_key", "exa_api_key"} {
s, ok := payload[key].(string)
if !ok || s == "" {
continue
}
enc, err := u.cipher.Encrypt(s)
if err != nil {
return err
}
payload[key] = enc
}
}
_, err := u.settings.Upsert(ctx, settingdomain.UpsertRequest{
Scope: settingScopeUser,
ScopeID: ownerUID,
Key: keyResearch,
Value: value.toMap(),
Value: payload,
})
return err
}

View File

@ -4,6 +4,7 @@ import (
"context"
"haixun-backend/internal/library/clock"
"haixun-backend/internal/library/crypto"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/model/threads_account/domain/entity"
@ -16,13 +17,14 @@ import (
type secretsMongoRepository struct {
collection *mongo.Collection
cipher *crypto.Cipher
}
func NewSecretsMongoRepository(db *mongo.Database) domrepo.SecretsRepository {
func NewSecretsMongoRepository(db *mongo.Database, cipher *crypto.Cipher) domrepo.SecretsRepository {
if db == nil {
return &secretsMongoRepository{}
return &secretsMongoRepository{cipher: cipher}
}
return &secretsMongoRepository{collection: db.Collection(entity.SecretsCollectionName)}
return &secretsMongoRepository{collection: db.Collection(entity.SecretsCollectionName), cipher: cipher}
}
func (r *secretsMongoRepository) EnsureIndexes(ctx context.Context) error {
@ -47,6 +49,13 @@ func (r *secretsMongoRepository) FindByAccountID(ctx context.Context, accountID
if err != nil {
return nil, err
}
if r.cipher != nil && out.BrowserStorageState != "" {
plain, decErr := r.cipher.Decrypt(out.BrowserStorageState)
if decErr != nil {
return nil, decErr
}
out.BrowserStorageState = plain
}
return &out, nil
}
@ -55,13 +64,21 @@ func (r *secretsMongoRepository) SaveBrowserStorageState(ctx context.Context, ac
return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
}
now := clock.NowUnixNano()
storedValue := storageState
if r.cipher != nil {
enc, encErr := r.cipher.Encrypt(storageState)
if encErr != nil {
return nil, encErr
}
storedValue = enc
}
var out entity.Secrets
err := r.collection.FindOneAndUpdate(
ctx,
bson.M{"_id": accountID},
bson.M{
"$set": bson.M{
"browser_storage_state": storageState,
"browser_storage_state": storedValue,
"update_at": now,
},
"$setOnInsert": bson.M{"_id": accountID},
@ -71,5 +88,7 @@ func (r *secretsMongoRepository) SaveBrowserStorageState(ctx context.Context, ac
if err != nil {
return nil, err
}
// Return plaintext to callers regardless of at-rest encryption.
out.BrowserStorageState = storageState
return &out, nil
}

View File

@ -129,7 +129,7 @@ func (u *threadsAccountUseCase) loadAiCredentials(ctx context.Context, ownerUID,
defaults := defaultAiCredentials()
setting, err := u.settings.Get(ctx, settingScopeUser, ownerUID, keyAiCredentials)
if err == nil {
return mergeAiCredentials(defaults, setting.Value), nil
return u.mergeAiCredentials(defaults, setting.Value)
}
if !isSettingNotFound(err) {
return defaults, err
@ -142,7 +142,10 @@ func (u *threadsAccountUseCase) loadAiCredentials(ctx context.Context, ownerUID,
}
return defaults, err
}
creds := mergeAiCredentials(defaults, legacy.Value)
creds, err := u.mergeAiCredentials(defaults, legacy.Value)
if err != nil {
return defaults, err
}
if saveErr := u.saveAiCredentials(ctx, ownerUID, creds); saveErr != nil {
return creds, saveErr
}
@ -150,18 +153,24 @@ func (u *threadsAccountUseCase) loadAiCredentials(ctx context.Context, ownerUID,
}
func (u *threadsAccountUseCase) saveAiCredentials(ctx context.Context, ownerUID string, creds aiCredentials) error {
_, err := u.settings.Upsert(ctx, settingdomain.UpsertRequest{
value, err := u.aiCredentialsToMap(creds)
if err != nil {
return err
}
_, err = u.settings.Upsert(ctx, settingdomain.UpsertRequest{
Scope: settingScopeUser,
ScopeID: ownerUID,
Key: keyAiCredentials,
Value: aiCredentialsToMap(creds),
Value: value,
})
return err
}
func mergeAiCredentials(defaults aiCredentials, value map[string]interface{}) aiCredentials {
// mergeAiCredentials merges stored setting values onto defaults, decrypting any
// at-rest encrypted API keys.
func (u *threadsAccountUseCase) mergeAiCredentials(defaults aiCredentials, value map[string]interface{}) (aiCredentials, error) {
if value == nil {
return defaults
return defaults, nil
}
if v, ok := value["provider"].(string); ok && strings.TrimSpace(v) != "" {
defaults.Provider = v
@ -178,13 +187,23 @@ func mergeAiCredentials(defaults aiCredentials, value map[string]interface{}) ai
if raw, ok := value["api_keys"].(map[string]interface{}); ok {
keys := map[string]string{}
for provider, item := range raw {
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
keys[provider] = strings.TrimSpace(s)
s, ok := item.(string)
if !ok || strings.TrimSpace(s) == "" {
continue
}
plain := strings.TrimSpace(s)
if u.cipher != nil {
decrypted, err := u.cipher.Decrypt(plain)
if err != nil {
return defaults, err
}
plain = strings.TrimSpace(decrypted)
}
keys[provider] = plain
}
defaults.ApiKeys = keys
}
return defaults
return defaults, nil
}
func applyAiSettingsPatch(current aiCredentials, patch domusecase.AiSettingsPatch) aiCredentials {
@ -215,10 +234,18 @@ func applyAiSettingsPatch(current aiCredentials, patch domusecase.AiSettingsPatc
return current
}
func aiCredentialsToMap(creds aiCredentials) map[string]interface{} {
func (u *threadsAccountUseCase) aiCredentialsToMap(creds aiCredentials) (map[string]interface{}, error) {
keys := map[string]interface{}{}
for provider, value := range creds.ApiKeys {
keys[provider] = value
stored := value
if u.cipher != nil {
enc, err := u.cipher.Encrypt(value)
if err != nil {
return nil, err
}
stored = enc
}
keys[provider] = stored
}
return map[string]interface{}{
"provider": creds.Provider,
@ -226,7 +253,7 @@ func aiCredentialsToMap(creds aiCredentials) map[string]interface{} {
"research_provider": creds.ResearchProvider,
"research_model": creds.ResearchModel,
"api_keys": keys,
}
}, nil
}
func toPublicAiSettings(accountID string, creds aiCredentials) *domusecase.AiSettings {

View File

@ -7,6 +7,7 @@ import (
"strings"
"haixun-backend/internal/library/clock"
"haixun-backend/internal/library/crypto"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/library/placement"
@ -34,6 +35,7 @@ type threadsAccountUseCase struct {
members memberdomain.UseCase
settings settingdomain.UseCase
personas personadomain.UseCase
cipher *crypto.Cipher
}
func NewUseCase(
@ -42,6 +44,7 @@ func NewUseCase(
members memberdomain.UseCase,
settings settingdomain.UseCase,
personas personadomain.UseCase,
cipher *crypto.Cipher,
) domusecase.UseCase {
return &threadsAccountUseCase{
repo: repo,
@ -49,6 +52,7 @@ func NewUseCase(
members: members,
settings: settings,
personas: personas,
cipher: cipher,
}
}

View File

@ -3,10 +3,12 @@ package svc
import (
"context"
"fmt"
"net/http"
"os"
"time"
"haixun-backend/internal/config"
libcrypto "haixun-backend/internal/library/crypto"
libmongo "haixun-backend/internal/library/mongo"
libredis "haixun-backend/internal/library/redis"
"haixun-backend/internal/library/validate"
@ -105,6 +107,11 @@ func NewServiceContext(c config.Config) *ServiceContext {
}
redisClient := libredis.NewClient(c.Redis)
secretsCipher, err := libcrypto.New(c.Secrets.EncryptionKey)
if err != nil {
panic(err)
}
settingRepository := settingrepo.NewMongoRepository(mongoClient.Database())
if err := settingRepository.EnsureIndexes(ctx); err != nil {
panic(err)
@ -241,7 +248,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
scanPostRepository,
)
threadsAccountRepository := threadsaccountrepo.NewMongoRepository(mongoClient.Database())
threadsAccountSecretsRepository := threadsaccountrepo.NewSecretsMongoRepository(mongoClient.Database())
threadsAccountSecretsRepository := threadsaccountrepo.NewSecretsMongoRepository(mongoClient.Database(), secretsCipher)
if err := threadsAccountRepository.EnsureIndexes(ctx); err != nil {
panic(err)
}
@ -254,9 +261,10 @@ func NewServiceContext(c config.Config) *ServiceContext {
memberUseCase,
settingUseCase,
personaUseCase,
secretsCipher,
)
placementUseCase := placementusecase.NewUseCase(settingUseCase)
placementUseCase := placementusecase.NewUseCase(settingUseCase, secretsCipher)
sc := &ServiceContext{
Config: c,
@ -375,8 +383,14 @@ func NewServiceContext(c config.Config) *ServiceContext {
go reaper.Start(reaperCtx, interval)
}
sc.AuthJWT = middleware.NewAuthJWTMiddleware(sc.AuthToken, sc.Config.Auth).Handle
sc.MemberAuth = middleware.NewMemberAuthMiddleware(sc.AuthToken, sc.Config.Auth).Handle
// 認證 + RBAC先驗 JWT把 actor 放進 context再用 catalog 權限做路由級授權。
// 兩者組合後掛在受保護的 route group 上routes.go 以 AuthJWT / MemberAuth 引用)。
authJWT := middleware.NewAuthJWTMiddleware(sc.AuthToken, sc.Config.Auth).Handle
memberAuth := middleware.NewMemberAuthMiddleware(sc.AuthToken, sc.Config.Auth).Handle
rbac := middleware.NewPermissionRBACMiddleware(sc.Member, sc.Permission).Handle
sc.AuthJWT = func(next http.HandlerFunc) http.HandlerFunc { return authJWT(rbac(next)) }
sc.MemberAuth = func(next http.HandlerFunc) http.HandlerFunc { return memberAuth(rbac(next)) }
sc.WorkerSecret = middleware.NewWorkerSecretMiddleware(sc.Config.InternalWorker).Handle
return sc

View File

@ -111,14 +111,16 @@ func (r *Runner) execute(ctx context.Context, run *entity.Run) {
}
}
percentage := (completedBefore * 100) / totalSteps
_, _ = r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{
if _, err := r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{
JobID: jobID,
WorkerID: r.workerID,
Phase: step.ID,
Summary: "running step " + step.ID,
Percentage: percentage,
Steps: steps,
})
}); err != nil {
log.Printf("job worker update progress (running) failed: job=%s step=%s err=%v", jobID, step.ID, err)
}
if err := r.runStep(ctx, run, template, step); err != nil {
if err == errJobCancelled {
@ -155,28 +157,32 @@ func (r *Runner) execute(ctx context.Context, run *entity.Run) {
}
completedBefore++
percentage = (completedBefore * 100) / totalSteps
_, _ = r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{
if _, err := r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{
JobID: jobID,
WorkerID: r.workerID,
Phase: step.ID,
Summary: "completed step " + step.ID,
Percentage: percentage,
Steps: steps,
})
}); err != nil {
log.Printf("job worker update progress (completed step) failed: job=%s step=%s err=%v", jobID, step.ID, err)
}
}
fresh, err := r.jobs.GetRun(ctx, jobID)
if err == nil && fresh != nil && fresh.Status.IsTerminal() {
return
}
_, _ = r.jobs.CompleteRun(ctx, domusecase.CompleteRunRequest{
if _, err := r.jobs.CompleteRun(ctx, domusecase.CompleteRunRequest{
JobID: jobID,
WorkerID: r.workerID,
Result: map[string]any{
"message": "demo long task completed",
"template": run.TemplateType,
"steps": len(template.Steps),
},
})
}); err != nil {
log.Printf("job worker complete run failed: job=%s err=%v", jobID, err)
}
}
var errJobCancelled = errors.New("job cancelled")

593
backend/worker/package-lock.json generated Normal file
View File

@ -0,0 +1,593 @@
{
"name": "haixun-node-worker",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "haixun-node-worker",
"dependencies": {
"playwright": "^1.49.1"
},
"devDependencies": {
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/tsx": {
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View File

@ -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() {
<ThemeProvider>
<AuthProvider>
<BrowserRouter>
<ErrorBoundary>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<Navigate to="/login" replace />} />
@ -78,6 +80,7 @@ export default function App() {
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</ErrorBoundary>
</BrowserRouter>
</AuthProvider>
</ThemeProvider>

View File

@ -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,21 +173,31 @@ 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) {
// 確保 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 = ''
let data = ''
const dataLines: string[] = []
for (const line of lines) {
if (line.startsWith('event:')) event = line.slice(6).trim()
if (line.startsWith('data:')) data = line.slice(5).trim()
else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, ''))
}
if (!data) continue
const data = dataLines.join('\n').trim()
if (!data) return false
try {
const parsed = JSON.parse(data) as {
type?: string
@ -191,16 +206,44 @@ async function consumeAIEventStream(
message?: string
}
if (event === 'error' || parsed.type === 'error') {
onError(parsed.message || 'stream error')
return
finishError(parsed.message || 'stream error')
return true
}
if (parsed.type === 'delta' && parsed.text) onDelta(parsed.text)
if (parsed.type === 'done') onDone(parsed.finish_reason)
if (parsed.type === 'done') {
finishDone(parsed.finish_reason)
return true
}
} catch {
/* 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<string, string> = { 'Content-Type': 'application/json' }
if (memberToken) headers.Authorization = `Bearer ${memberToken}`
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)
await consumeAIEventStream(res, onDelta, onDone, onError, signal)
} catch (err) {
if (signal?.aborted) {
onDone()
return
}
onError(err instanceof Error ? err.message : '無法連線島民 API')
}
}

View File

@ -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],
)

View File

@ -0,0 +1,50 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
type ErrorBoundaryProps = {
children: ReactNode
fallback?: ReactNode
}
type ErrorBoundaryState = {
hasError: boolean
message: string
}
/**
* render AI IslanderMarkdown
*
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false, message: '' }
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
return { hasError: true, message: error instanceof Error ? error.message : '發生未預期錯誤' }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('ErrorBoundary caught error:', error, info.componentStack)
}
private handleReset = () => {
this.setState({ hasError: false, message: '' })
}
render() {
if (!this.state.hasError) return this.props.children
if (this.props.fallback) return this.props.fallback
return (
<div className="ac-slot" style={{ padding: '1.5rem', textAlign: 'center' }}>
<p className="text-ink" style={{ marginBottom: '0.75rem' }}>
</p>
<p className="text-subtle" style={{ marginBottom: '1rem', fontSize: 13 }}>
{this.state.message}
</p>
<button type="button" className="ac-btn-secondary" onClick={this.handleReset}>
</button>
</div>
)
}
}

View File

@ -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) => {

View File

@ -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<AbortController | null>(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,
)
})
},

View File

@ -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 (
<Link to={href} className="ac-link font-semibold">
{children}

View File

@ -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<boolean> {
@ -39,6 +42,7 @@ export function waitForExtensionBridge(timeoutMs = 8000): Promise<boolean> {
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,
)
})
}

View File

@ -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

View File

@ -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,

View File

@ -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)
},
}

12
infra/.env.example Normal file
View File

@ -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

103
infra/README.md Normal file
View File

@ -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`(不進 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機敏資料落地加密
```

42
infra/docker-compose.yml Normal file
View File

@ -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:

View File

@ -0,0 +1,23 @@
# 部署到目標主機的 /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

56
infra/nginx/haixun.conf Normal file
View File

@ -0,0 +1,56 @@
# 巡樓 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

@ -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
# 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

@ -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

View File

@ -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