feat/rebone #3

Merged
daniel.w merged 8 commits from feat/rebone into main 2026-07-06 06:13:18 +00:00
13 changed files with 307 additions and 14 deletions
Showing only changes of commit 27a8a71d00 - Show all commits

View File

@ -13,9 +13,13 @@ DEPLOY_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.prod -f $(DEPLOY_
.PHONY: help
help: ## 顯示可用指令
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
@grep -E '^[a-zA-Z0-9_-]+:.*## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
.PHONY: setup-env
setup-env: ## 互動式產生 deploy/.env.prod自動 URL-encode MongoDB 密碼)
@$(DEPLOY_DIR)/setup.sh
.PHONY: gen-api
gen-api: ## 重新產生後端 API handler / logic / types
$(MAKE) -C $(BACKEND_DIR) gen-api
@ -28,6 +32,10 @@ fmt: ## 格式化後端 Go 程式
test: ## 執行後端測試
$(MAKE) -C $(BACKEND_DIR) test
.PHONY: init
init: ## 初始化系統(建索引、種權限、建立 admin讀 deploy/.env.prod
$(MAKE) -C $(BACKEND_DIR) ENV_FILE=../deploy/.env.prod init
.PHONY: run
run: ## 啟動後端 API
$(MAKE) -C $(BACKEND_DIR) run
@ -62,22 +70,42 @@ dev-infra: ## 啟動本機 dev 資料服務Mongo / Redis 容器,讀 deploy/
dev-infra-down: ## 關閉本機 dev 資料服務(保留資料 volume
$(INFRA_DEV_COMPOSE) down
.PHONY: prod-install
prod-install: ## 一鍵安裝 prod複製 .env.prod、啟動 infra、init、啟動 app
.PHONY: dev-infra-recreate
dev-infra-recreate: ## 清除 dev 資料 volume 並重啟Mongo / Redis 全新狀態)
$(INFRA_DEV_COMPOSE) down -v
$(INFRA_DEV_COMPOSE) up -d
$(INFRA_DEV_COMPOSE) ps
.PHONY: prod-infra
prod-infra: ## 啟動 prod 資料服務Mongo / Redis讀 deploy/.env.prod
@test -f $(DEPLOY_DIR)/.env.prod || cp $(DEPLOY_DIR)/.env.prod.example $(DEPLOY_DIR)/.env.prod
@echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.prod 再執行後續指令" >&2
$(INFRA_PROD_COMPOSE) up -d
$(INFRA_PROD_COMPOSE) ps
$(DEPLOY_COMPOSE) pull
.PHONY: prod-infra-down
prod-infra-down: ## 關閉 prod 資料服務(保留資料 volume
$(INFRA_PROD_COMPOSE) down
.PHONY: prod-infra-recreate
prod-infra-recreate: ## 清除 prod 資料 volume 並重啟Mongo / Redis 全新狀態)
$(INFRA_PROD_COMPOSE) down -v
$(INFRA_PROD_COMPOSE) up -d
$(INFRA_PROD_COMPOSE) ps
.PHONY: prod-install
prod-install: ## 一鍵部署 prod複製 .env.prod、build 本地 image、啟動 app、init需先啟動 infra
@test -f $(DEPLOY_DIR)/.env.prod || cp $(DEPLOY_DIR)/.env.prod.example $(DEPLOY_DIR)/.env.prod
@echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.prod 再執行後續指令" >&2
@echo ">>> 請確認 infra 已啟動make prod-infra" >&2
$(DEPLOY_COMPOSE) build
$(DEPLOY_COMPOSE) up -d
$(DEPLOY_COMPOSE) --profile init run --rm init
$(DEPLOY_COMPOSE) ps
.PHONY: prod-rebuild
prod-rebuild: ## 重新拉取 image 並重建 prod stackinfra + app
$(INFRA_PROD_COMPOSE) pull
$(INFRA_PROD_COMPOSE) up -d
$(DEPLOY_COMPOSE) pull
prod-rebuild: ## 重新 build 本地 image 並重建 prod app stackinfra 不影響)
$(DEPLOY_COMPOSE) build
$(DEPLOY_COMPOSE) up -d
$(DEPLOY_COMPOSE) ps
@ -89,3 +117,12 @@ prod-stop: ## 關閉 prod app stack保留 infra 與資料)
prod-stop-all: ## 關閉 prod app + infra保留資料 volume
$(DEPLOY_COMPOSE) down
$(INFRA_PROD_COMPOSE) down
.PHONY: prod-recreate
prod-recreate: ## 完整清除 prod 全部資料 volume + 重建啟動app + infra + init
$(DEPLOY_COMPOSE) down -v
$(INFRA_PROD_COMPOSE) down -v
$(INFRA_PROD_COMPOSE) up -d
$(DEPLOY_COMPOSE) up -d
$(DEPLOY_COMPOSE) --profile init run --rm init
$(DEPLOY_COMPOSE) ps

View File

@ -13,6 +13,7 @@ export HAIXUN_MONGO_URI HAIXUN_MONGO_DB HAIXUN_REDIS_ADDR HAIXUN_REDIS_PASSWORD
export HAIXUN_JWT_ACCESS_SECRET HAIXUN_JWT_REFRESH_SECRET HAIXUN_WORKER_SECRET
export HAIXUN_SECRETS_KEY HAIXUN_BACKEND_URL
export HAIXUN_WORKER_POLL_MS HAIXUN_8D_TARGET_SAMPLES PLAYWRIGHT_HEADLESS
export INIT_TENANT_ID INIT_ADMIN_EMAIL INIT_ADMIN_PASSWORD INIT_ADMIN_DISPLAY_NAME
endif
export HAIXUN_JOB_WORKER_TYPE ?= go-demo
@ -36,6 +37,9 @@ run:
run-dev:
go run gateway.go -f etc/gateway.yaml
init:
go run ./cmd/tool/main.go init -f etc/gateway.yaml
web-dev:
cd web && npm install && npm run dev

View File

@ -36,6 +36,18 @@ func EnsureAdminMember(ctx context.Context, repo domrepo.Repository, opts AdminO
return nil, false, err
}
existing.Roles = []string{"admin"}
if err := bcrypt.CompareHashAndPassword([]byte(existing.PasswordHash), []byte(opts.Password)); err != nil {
hash, hashErr := bcrypt.GenerateFromPassword([]byte(opts.Password), bcrypt.DefaultCost)
if hashErr != nil {
return nil, false, app.For(code.Member).SysInternal("rehash password failed").WithCause(hashErr)
}
if updateErr := repo.UpdatePassword(ctx, tenantID, existing.UID, string(hash)); updateErr != nil {
return nil, false, updateErr
}
existing.PasswordHash = string(hash)
}
return existing, false, nil
}
if e := app.FromError(err); e == nil || e.Category() != code.ResNotFound {

View File

@ -68,6 +68,16 @@ func (m *memoryMemberRepo) SetActiveThreadsAccountID(_ context.Context, tenantID
return app.For(code.Member).ResNotFound("member not found")
}
func (m *memoryMemberRepo) UpdatePassword(_ context.Context, tenantID, uid, passwordHash string) error {
for _, item := range m.byEmail {
if item.TenantID == tenantID && item.UID == uid {
item.PasswordHash = passwordHash
return nil
}
}
return app.For(code.Member).ResNotFound("member not found")
}
func (m *memoryMemberRepo) SetRoles(_ context.Context, tenantID, uid string, roles []string) error {
for _, item := range m.byEmail {
if item.TenantID == tenantID && item.UID == uid {
@ -123,4 +133,72 @@ func TestEnsureAdminMemberUpgradesExisting(t *testing.T) {
if len(member.Roles) != 1 || member.Roles[0] != "admin" {
t.Fatalf("roles=%v", member.Roles)
}
if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte("Admin-Pass-1!")); err != nil {
t.Fatalf("password should have been synced: %v", err)
}
}
func TestEnsureAdminMemberSyncsPasswordWhenChanged(t *testing.T) {
oldHash, err := bcrypt.GenerateFromPassword([]byte("Old-Pass-1!"), bcrypt.DefaultCost)
if err != nil {
t.Fatal(err)
}
repo := &memoryMemberRepo{byEmail: map[string]*entity.Member{
memberKey("default", "admin@haixun.local"): {
TenantID: "default",
UID: "uid-1",
Email: "admin@haixun.local",
Roles: []string{"admin"},
PasswordHash: string(oldHash),
},
}}
_, created, err := EnsureAdminMember(context.Background(), repo, AdminOptions{
TenantID: "default",
Email: "admin@haixun.local",
Password: "New-Pass-2@",
})
if err != nil {
t.Fatalf("EnsureAdminMember: %v", err)
}
if created {
t.Fatal("expected created=false")
}
member := repo.byEmail[memberKey("default", "admin@haixun.local")]
if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte("Old-Pass-1!")); err == nil {
t.Fatal("password should have been updated, but old hash still matches")
}
if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte("New-Pass-2@")); err != nil {
t.Fatalf("new password hash does not match: %v", err)
}
}
func TestEnsureAdminMemberKeepsPasswordWhenSame(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("Same-Pass-1!"), bcrypt.DefaultCost)
if err != nil {
t.Fatal(err)
}
repo := &memoryMemberRepo{byEmail: map[string]*entity.Member{
memberKey("default", "admin@haixun.local"): {
TenantID: "default",
UID: "uid-1",
Email: "admin@haixun.local",
Roles: []string{"admin"},
PasswordHash: string(hash),
},
}}
_, created, err := EnsureAdminMember(context.Background(), repo, AdminOptions{
TenantID: "default",
Email: "admin@haixun.local",
Password: "Same-Pass-1!",
})
if err != nil {
t.Fatalf("EnsureAdminMember: %v", err)
}
if created {
t.Fatal("expected created=false")
}
member := repo.byEmail[memberKey("default", "admin@haixun.local")]
if member.PasswordHash != string(hash) {
t.Fatal("password hash should not change when password is the same")
}
}

View File

@ -1,6 +1,7 @@
package style8d
import (
"bytes"
"encoding/json"
"fmt"
"math"
@ -440,7 +441,23 @@ func extractJSONObject(raw string) ([]byte, error) {
if start < 0 || end <= start {
return nil, fmt.Errorf("LLM response does not contain JSON object")
}
return []byte(text[start : end+1]), nil
return repairTruncatedJSON([]byte(text[start : end+1])), nil
}
// repairTruncatedJSON adds missing closing braces/brackets when the LLM output
// was cut off mid-JSON. Returns nil if no repair was needed or possible.
func repairTruncatedJSON(raw []byte) []byte {
opens := bytes.Count(raw, []byte("{"))
closes := bytes.Count(raw, []byte("}"))
if closes < opens {
raw = append(raw, bytes.Repeat([]byte("}"), opens-closes)...)
}
bracketOpens := bytes.Count(raw, []byte("["))
bracketCloses := bytes.Count(raw, []byte("]"))
if bracketCloses < bracketOpens {
raw = append(raw, bytes.Repeat([]byte("]"), bracketOpens-bracketCloses)...)
}
return raw
}
func parseDimension(root map[string]json.RawMessage, keys ...string) Dimension {

View File

@ -99,6 +99,7 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal
if err != nil {
return nil, app.For(code.AI).SysInternal("prompt config load failed")
}
maxTokens := 8192
result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
Provider: providerID,
Model: credential.Model,
@ -109,6 +110,7 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal
Messages: []domai.Message{
{Role: "user", Content: input.BuildUserPrompt()},
},
MaxTokens: &maxTokens,
})
if err != nil {
if strings.Contains(err.Error(), "HTTP 401") {

View File

@ -83,6 +83,7 @@ func (l *StartPersonaStyleAnalysisFromTextLogic) StartPersonaStyleAnalysisFromTe
if err != nil {
return nil, app.For(code.AI).SysInternal("prompt config load failed")
}
maxTokens := 8192
result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
Provider: providerID,
Model: credential.Model,
@ -93,6 +94,7 @@ func (l *StartPersonaStyleAnalysisFromTextLogic) StartPersonaStyleAnalysisFromTe
Messages: []domai.Message{
{Role: "user", Content: input.BuildUserPrompt()},
},
MaxTokens: &maxTokens,
})
if err != nil {
if strings.Contains(err.Error(), "HTTP 401") {

View File

@ -22,4 +22,5 @@ type Repository interface {
UpdateProfile(ctx context.Context, tenantID, uid string, update ProfileUpdate) (*entity.Member, error)
SetRoles(ctx context.Context, tenantID, uid string, roles []string) error
SetActiveThreadsAccountID(ctx context.Context, tenantID, uid, accountID string) error
UpdatePassword(ctx context.Context, tenantID, uid, passwordHash string) error
}

View File

@ -148,6 +148,27 @@ func (r *mongoRepository) UpdateProfile(ctx context.Context, tenantID, uid strin
return &out, err
}
func (r *mongoRepository) UpdatePassword(ctx context.Context, tenantID, uid, passwordHash string) error {
if r.collection == nil {
return app.For(code.Member).DBUnavailable("Mongo is not configured")
}
if tenantID == "" || uid == "" {
return app.For(code.Member).InputMissingRequired("tenant_id and uid are required")
}
res, err := r.collection.UpdateOne(
ctx,
bson.M{"tenant_id": tenantID, "uid": uid},
bson.M{"$set": bson.M{"password_hash": passwordHash, "update_at": clock.NowUnixNano()}},
)
if err != nil {
return err
}
if res.MatchedCount == 0 {
return app.For(code.Member).ResNotFound("member not found")
}
return nil
}
func (r *mongoRepository) findOne(ctx context.Context, filter bson.M) (*entity.Member, error) {
if r.collection == nil {
return nil, app.For(code.Member).DBUnavailable("Mongo is not configured")

View File

@ -1,5 +1,8 @@
# 巡樓 Prod 環境deploy/docker-compose.yml 讀這份)
# 複製成 deploy/.env.prod 再填值;.env.prod 不要 commit。
# 不要手動編輯!請執行以下指令自動產生:
# make setup-env
# 或直接執行:
# bash deploy/setup.sh
# --- infra composeMongo / Redis 容器,與下方 HAIXUN_MONGO_URI / HAIXUN_REDIS_PASSWORD 密碼必須一致)---
MONGO_PORT=27017
@ -20,12 +23,11 @@ NODE_WORKER_TAG=v0.0.1
WEB_PORT=80
HAIXUN_MONGO_URI=mongodb://haixun:CHANGE_ME_URL_ENCODED@mongo:27017/?authSource=admin
# ⚠ 以下由 setup.sh 自動填入,手動改很容易忘記 URL-encode
HAIXUN_MONGO_URI=mongodb://haixun:CHANGE_ME@mongo:27017/?authSource=admin
HAIXUN_MONGO_DB=haixun
HAIXUN_REDIS_ADDR=redis:6379
HAIXUN_REDIS_PASSWORD=CHANGE_ME
# ⚠ HAIXUN_MONGO_URI 的密碼必須 URL-encode 且等於上方 MONGO_ROOT_PASSWORD
# HAIXUN_REDIS_PASSWORD 必須等於上方 REDIS_PASSWORD。
# openssl rand -base64 48
HAIXUN_JWT_ACCESS_SECRET=CHANGE_ME

View File

@ -40,6 +40,9 @@ x-worker-service: &worker-service
services:
web:
build:
context: ..
dockerfile: backend/web/Dockerfile
image: ${WEB_IMAGE:-ghcr.io/kupi-cat/haixun-web}:${WEB_TAG:-v0.0.1}
restart: unless-stopped
ports:
@ -50,6 +53,9 @@ services:
- app
gateway:
build:
context: ..
dockerfile: backend/Dockerfile
image: ${BACKEND_IMAGE:-ghcr.io/kupi-cat/haixun-backend}:${BACKEND_TAG:-v0.0.1}
restart: unless-stopped
command: ["/app/gateway", "-f", "/app/etc/gateway.prod.yaml"]
@ -175,6 +181,9 @@ services:
HAIXUN_JOB_WORKER_ID: go-refill-publish-inventory
node-worker-style-8d:
build:
context: ..
dockerfile: backend/worker/Dockerfile
image: ${NODE_WORKER_IMAGE:-ghcr.io/kupi-cat/haixun-node-worker}:${NODE_WORKER_TAG:-v0.0.1}
restart: unless-stopped
command: ["npm", "run", "style-8d"]

108
deploy/setup.sh Executable file
View File

@ -0,0 +1,108 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUTPUT="$SCRIPT_DIR/.env.prod"
echo "=============================================="
echo " 巡樓 Prod 環境設定"
echo "=============================================="
echo ""
# --- Mongo ---
read -p "Mongo root password (留空自動產生): " MONGO_ROOT_PASSWORD
if [ -z "$MONGO_ROOT_PASSWORD" ]; then
MONGO_ROOT_PASSWORD=$(openssl rand -base64 32)
echo " 自動產生: $MONGO_ROOT_PASSWORD"
fi
MONGO_ROOT_PASSWORD_ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$MONGO_ROOT_PASSWORD', safe=''))")
# --- Redis ---
read -p "Redis password (留空自動產生): " REDIS_PASSWORD
if [ -z "$REDIS_PASSWORD" ]; then
REDIS_PASSWORD=$(openssl rand -base64 32)
echo " 自動產生: $REDIS_PASSWORD"
fi
# --- Admin ---
read -p "Admin email [admin@example.com]: " ADMIN_EMAIL
ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
while true; do
read -sp "Admin password (至少 8 字): " ADMIN_PASSWORD
echo ""
if [ ${#ADMIN_PASSWORD} -ge 8 ]; then
break
fi
echo " 密碼太短,再試一次"
done
# --- Secrets (auto-generate) ---
JWT_ACCESS_SECRET=$(openssl rand -base64 48)
JWT_REFRESH_SECRET=$(openssl rand -base64 48)
WORKER_SECRET=$(openssl rand -base64 48)
SECRETS_KEY=$(openssl rand -base64 32)
# --- Write .env.prod ---
cat > "$OUTPUT" << ENDOFFILE
# 巡樓 Prod 環境(由 deploy/setup.sh 自動產生,請勿手動編輯)
# infra composeMongo / Redis 容器,與下方 HAIXUN_MONGO_URI / HAIXUN_REDIS_PASSWORD 密碼一致)
MONGO_PORT=27017
MONGO_ROOT_USER=haixun
MONGO_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
MONGO_DATABASE=haixun
REDIS_PORT=6379
REDIS_PASSWORD=${REDIS_PASSWORD}
# app compose
WEB_IMAGE=ghcr.io/kupi-cat/haixun-web
WEB_TAG=v0.0.1
BACKEND_IMAGE=ghcr.io/kupi-cat/haixun-backend
BACKEND_TAG=v0.0.1
NODE_WORKER_IMAGE=ghcr.io/kupi-cat/haixun-node-worker
NODE_WORKER_TAG=v0.0.1
WEB_PORT=80
HAIXUN_MONGO_URI=mongodb://haixun:${MONGO_ROOT_PASSWORD_ENCODED}@mongo:27017/?authSource=admin
HAIXUN_MONGO_DB=haixun
HAIXUN_REDIS_ADDR=redis:6379
HAIXUN_REDIS_PASSWORD=${REDIS_PASSWORD}
HAIXUN_JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
HAIXUN_JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
HAIXUN_WORKER_SECRET=${WORKER_SECRET}
HAIXUN_SECRETS_KEY=${SECRETS_KEY}
THREADS_APP_ID=
THREADS_APP_SECRET=
THREADS_REDIRECT_URI=http://your-domain.example/api/v1/threads-accounts/oauth/callback
THREADS_OAUTH_SUCCESS_REDIRECT=http://your-domain.example
INIT_TENANT_ID=default
INIT_ADMIN_EMAIL=${ADMIN_EMAIL}
INIT_ADMIN_PASSWORD=${ADMIN_PASSWORD}
INIT_ADMIN_DISPLAY_NAME=Admin
HAIXUN_WORKER_POLL_MS=3000
HAIXUN_8D_TARGET_SAMPLES=4
# Worker / sweeper 行為
HAIXUN_JOB_SCHEDULER_ENABLED=false
HAIXUN_JOB_SCHEDULER_INTERVAL_SECONDS=60
HAIXUN_JOB_REAPER_ENABLED=false
HAIXUN_JOB_REAPER_INTERVAL_SECONDS=30
HAIXUN_THREADS_TOKEN_REFRESH_ENABLED=false
HAIXUN_THREADS_TOKEN_REFRESH_INTERVAL_SECONDS=3600
HAIXUN_THREADS_TOKEN_REFRESH_LEAD_DAYS=7
HAIXUN_THREADS_PUBLISH_QUEUE_ENABLED=false
HAIXUN_THREADS_PUBLISH_QUEUE_INTERVAL_SECONDS=60
HAIXUN_THREADS_PUBLISH_QUEUE_BATCH_SIZE=20
ENDOFFILE
echo ""
echo "=============================================="
echo " 已完成!產出: $OUTPUT"
echo " 接著執行: make prod-infra && make prod-install"
echo "=============================================="

View File

@ -1,7 +1,7 @@
# 巡樓資料服務Mongo + Redis
# 只綁 127.0.0.1,給同主機上以 systemd 跑的 Go gateway / worker 連線。
# 啟動devmake dev-infra # 讀 deploy/.env.dev
# 啟動prodmake prod-install # 讀 deploy/.env.prod
# 啟動prodmake prod-infra # 讀 deploy/.env.prod
name: haixun-infra
services: