first commit

This commit is contained in:
王性驊 2026-06-26 16:56:07 +08:00
parent 232111712d
commit 8b0985f604
33 changed files with 0 additions and 1472 deletions

View File

@ -1,98 +0,0 @@
GO ?= go
GOFMT ?= gofmt
GOCTL ?= goctl
GO_ZERO_STYLE := go_zero
API_ENTRY := ./generate/api/gateway.api
GOFILES := $(shell find . -name '*.go')
.DEFAULT_GOAL := help
help: ## 顯示可用指令
@echo "Haixun Backend"
@echo ""
@grep -E '^[a-zA-Z0-9_-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "}; {printf " make %-12s %s\n", $$1, $$2}'
tools: ## 安裝 goctl / goimports
@command -v $(GOCTL) >/dev/null 2>&1 || (echo ">> installing goctl" && $(GO) install github.com/zeromicro/go-zero/tools/goctl@latest)
@command -v goimports >/dev/null 2>&1 || (echo ">> installing goimports" && $(GO) install golang.org/x/tools/cmd/goimports@latest)
gen-api: tools ## 由 .api 生成 handler / logic / types
$(GOCTL) api go -api $(API_ENTRY) -dir . -style $(GO_ZERO_STYLE) -home generate/goctl
fmt: ## gofmt + goimports
$(GOFMT) -s -w $(GOFILES)
@command -v goimports >/dev/null 2>&1 && goimports -w . || true
test: ## 執行測試
$(GO) test ./...
run: ## 啟動 API前景
$(GO) run ./gateway.go -f etc/gateway.yaml
dev-all: ## 一鍵啟動 Mongo/Redis + API + 前端 + 8D worker背景
bash scripts/start-all.sh
stop-all: ## 一鍵停止全部開發服務
bash scripts/stop-all.sh
restart-all: ## 一鍵重啟全部開發服務
bash scripts/restart-all.sh
status-all: ## 查看全部開發服務狀態
bash scripts/status-all.sh
stop: stop-all ## 同 stop-all
restart: restart-all ## 同 restart-all
dev-8d: ## 一鍵啟動 API + Node 8D worker前景Ctrl+C 結束)
bash scripts/dev-with-style-8d.sh
CONFIG ?= etc/gateway.yaml
INIT_TENANT ?= default
INIT_EMAIL ?= admin@30cm.net
INIT_PASSWORD ?= Fafafa54088
tool-init: ## 初始化 Mongo indexes、預設權限與 admin 帳號
$(GO) run ./cmd/tool init -f $(CONFIG) -tenant $(INIT_TENANT) -email $(INIT_EMAIL) -password '$(INIT_PASSWORD)'
tool: ## 執行 cmd/toolmake tool ARGS="init -f etc/gateway.yaml"
$(GO) run ./cmd/tool $(ARGS)
web-install: ## 安裝前端依賴
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

View File

@ -1,40 +0,0 @@
# 複製為 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

View File

@ -1,40 +0,0 @@
# 複製為 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

View File

@ -1,22 +0,0 @@
# 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

@ -1,9 +0,0 @@
# 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"]

View File

@ -1,14 +0,0 @@
# 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

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

View File

@ -1,64 +0,0 @@
# 本機依賴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

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

@ -1,35 +0,0 @@
Name: haixun-worker
Host: 0.0.0.0
Port: 8891
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: true
WorkerType: go
JobScheduler:
Enabled: false
IntervalSeconds: 60
JobReaper:
Enabled: false
IntervalSeconds: 30

View File

@ -1,110 +0,0 @@
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:

View File

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

@ -1,15 +0,0 @@
#!/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

@ -1,22 +0,0 @@
#!/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

@ -1,15 +0,0 @@
#!/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

@ -1,31 +0,0 @@
// 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.');

View File

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

View File

@ -1,71 +0,0 @@
#!/usr/bin/env bash
# 直接對比 OpenCode upstream 與本機 gateway 的原始 JSON/SSE方便排查空回應
#
# 用法:
# OPENCODE_TOKEN=sk-xxx ./scripts/debug-opencode-raw.sh
set -u
BASE_URL="${BASE_URL:-http://127.0.0.1:8890}"
OPENCODE_TOKEN="${OPENCODE_TOKEN:-}"
MODEL="${MODEL:-deepseek-v4-flash}"
MESSAGE="${MESSAGE:-Introduce yourself in one sentence.}"
if [[ -z "$OPENCODE_TOKEN" ]]; then
echo "OPENCODE_TOKEN is required"
exit 1
fi
require_cmd() {
command -v "$1" >/dev/null 2>&1 || { echo "missing command: $1"; exit 1; }
}
require_cmd curl
require_cmd jq
payload="$(jq -n \
--arg model "$MODEL" \
--arg message "$MESSAGE" \
'{
model: $model,
messages: [{role: "user", content: $message}],
max_tokens: 2048,
thinking: {type: "disabled"}
}')"
echo "=== OpenCode upstream (non-stream) ==="
curl -sS -m 120 -X POST "https://opencode.ai/zen/go/v1/chat/completions" \
-H "Authorization: Bearer ${OPENCODE_TOKEN}" \
-H "Content-Type: application/json" \
-d "$payload" | jq .
echo ""
echo "=== Local gateway (non-stream) ==="
curl -sS -m 120 -X POST "${BASE_URL}/api/v1/ai/chat" \
-H "Authorization: Bearer ${OPENCODE_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg provider "opencode-go" \
--arg model "$MODEL" \
--arg message "$MESSAGE" \
'{provider:$provider,model:$model,messages:[{role:"user",content:$message}],max_tokens:2048}')" | jq .
echo ""
echo "=== OpenCode upstream (stream, first 20 lines) ==="
curl -sS -N -m 120 -X POST "https://opencode.ai/zen/go/v1/chat/completions" \
-H "Authorization: Bearer ${OPENCODE_TOKEN}" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d "$(echo "$payload" | jq '.stream=true')" | head -n 20
echo ""
echo "=== Local gateway (stream, first 20 lines) ==="
curl -sS -N -m 120 -X POST "${BASE_URL}/api/v1/ai/chat/stream" \
-H "Authorization: Bearer ${OPENCODE_TOKEN}" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d "$(jq -n \
--arg provider "opencode-go" \
--arg model "$MODEL" \
--arg message "$MESSAGE" \
'{provider:$provider,model:$model,messages:[{role:"user",content:$message}],max_tokens:2048}')" | head -n 20

View File

@ -1,72 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BACKEND_DIR="$ROOT_DIR/haixun-backend"
CONFIG_FILE="${HAIXUN_BACKEND_CONFIG:-etc/gateway.yaml}"
BACKEND_URL="${HAIXUN_BACKEND_URL:-http://127.0.0.1:8890}"
pids=()
owned_pids=()
cleanup() {
local code=$?
if ((${#owned_pids[@]} > 0)); then
echo ""
echo "[dev-8d] stopping backend and worker..."
kill "${owned_pids[@]}" 2>/dev/null || true
wait "${owned_pids[@]}" 2>/dev/null || true
fi
exit "$code"
}
trap cleanup EXIT INT TERM
if curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then
echo "[dev-8d] backend already running: $BACKEND_URL"
else
echo "[dev-8d] starting Go backend: $CONFIG_FILE"
(
cd "$BACKEND_DIR"
go run ./gateway.go -f "$CONFIG_FILE"
) &
pids+=("$!")
owned_pids+=("$!")
echo "[dev-8d] waiting for backend health..."
for _ in $(seq 1 30); do
if curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then
break
fi
if ! kill -0 "${pids[0]}" 2>/dev/null; then
wait "${pids[0]}"
fi
sleep 1
done
if ! curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then
echo "[dev-8d] backend health check timed out: $BACKEND_URL" >&2
exit 1
fi
fi
echo "[dev-8d] starting Node 8D worker"
(
cd "$ROOT_DIR"
npm run worker:style-8d
) &
pids+=("$!")
owned_pids+=("$!")
echo "[dev-8d] running pids=${pids[*]}"
echo "[dev-8d] press Ctrl+C to stop both"
while true; do
for pid in "${owned_pids[@]}"; do
if ! kill -0 "$pid" 2>/dev/null; then
wait "$pid"
exit $?
fi
done
sleep 1
done

View File

@ -1,24 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
EXT_DIR="$ROOT_DIR/extension/haixun-threads-sync"
OUT_DIR="$ROOT_DIR/haixun-backend/web/public/downloads"
OUT_FILE="$OUT_DIR/haixun-threads-sync.zip"
if [[ ! -f "$EXT_DIR/manifest.json" ]]; then
echo "extension not found: $EXT_DIR" >&2
exit 1
fi
mkdir -p "$OUT_DIR"
rm -f "$OUT_FILE"
(
cd "$(dirname "$EXT_DIR")"
zip -qr "$OUT_FILE" "$(basename "$EXT_DIR")" \
-x "*.DS_Store" -x "*__MACOSX*"
)
VERSION="$(python3 -c "import json; print(json.load(open('$EXT_DIR/manifest.json'))['version'])")"
echo "packed haixun-threads-sync v$VERSION -> $OUT_FILE"

View File

@ -1,201 +0,0 @@
#!/usr/bin/env bash
# shellcheck disable=SC2034
# Shared helpers for production Docker scripts.
_PROD_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKEND_DIR="$(cd "$_PROD_SCRIPT_DIR/.." && pwd)"
DEPLOY_DIR="$BACKEND_DIR/deploy"
COMPOSE_FILE="$DEPLOY_DIR/docker-compose.prod.yml"
ENV_FILE="$DEPLOY_DIR/.env"
ENV_EXAMPLE="$DEPLOY_DIR/.env.example"
prod_common_init() {
:
}
prod_load_env() {
prod_common_init
if [[ ! -f "$ENV_FILE" ]]; then
if [[ -f "$ENV_EXAMPLE" ]]; then
cp "$ENV_EXAMPLE" "$ENV_FILE"
echo "[prod] created $ENV_FILE from .env.example — 請先修改密鑰與管理員密碼"
else
echo "[prod] missing $ENV_FILE" >&2
exit 1
fi
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
GO_REPLICAS="${GO_WORKER_REPLICAS:-1}"
NODE_REPLICAS="${NODE_STYLE8D_WORKER_REPLICAS:-1}"
WEB_PORT="${HAIXUN_WEB_PORT:-8080}"
MONGO_DB="${HAIXUN_MONGO_DATABASE:-haixun}"
}
prod_require_docker() {
if ! command -v docker >/dev/null 2>&1; then
echo "[prod] docker is required" >&2
exit 1
fi
}
prod_compose() {
prod_common_init
docker compose -f "$COMPOSE_FILE" "$@"
}
prod_service_health() {
local service="$1"
prod_compose ps --format json "$service" 2>/dev/null \
| grep -o '"Health":"[^"]*"' \
| head -1 \
| cut -d'"' -f4 \
|| true
}
prod_named_container_health() {
local name="$1"
docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{if .State.Running}}running{{else}}stopped{{end}}{{end}}' \
"$name" 2>/dev/null || true
}
prod_named_container_running() {
local name="$1"
[[ "$(docker inspect --format '{{.State.Running}}' "$name" 2>/dev/null || echo false)" == "true" ]]
}
prod_deps_healthy() {
local mongo_ok redis_ok
mongo_ok="$(prod_service_health mongo)"
redis_ok="$(prod_service_health redis)"
if [[ "$mongo_ok" == "healthy" && "$redis_ok" == "healthy" ]]; then
return 0
fi
# compose ps 偶發失敗時,改查實際 container避免重複 create 撞名)
mongo_ok="$(prod_named_container_health haixun-prod-mongo-1)"
redis_ok="$(prod_named_container_health haixun-prod-redis-1)"
[[ "$mongo_ok" == "healthy" && "$redis_ok" == "healthy" ]]
}
prod_wait_deps_healthy() {
echo "[prod] waiting for mongo/redis..."
for _ in $(seq 1 90); do
if prod_deps_healthy; then
return 0
fi
sleep 1
done
echo "[prod] mongo/redis did not become healthy in time" >&2
exit 1
}
prod_ensure_deps() {
if prod_deps_healthy; then
echo "[prod] mongo + redis already healthy — 略過重啟(資料在 named volume"
return 0
fi
echo "[prod] starting mongo + redis..."
if prod_named_container_running haixun-prod-mongo-1 && prod_named_container_running haixun-prod-redis-1; then
prod_compose start mongo redis 2>/dev/null \
|| prod_compose up -d --no-recreate mongo redis
else
prod_compose up -d mongo redis
fi
prod_wait_deps_healthy
}
prod_mongo_has_members() {
prod_compose exec -T mongo mongosh --quiet "$MONGO_DB" --eval \
'db.members.countDocuments({})' 2>/dev/null \
| tr -d '\r' \
| grep -Eq '^[1-9][0-9]*$'
}
prod_should_skip_init() {
if [[ "${HAIXUN_SKIP_INIT:-0}" == "1" ]]; then
return 0
fi
if [[ "${PROD_FORCE_INIT:-0}" == "1" ]]; then
return 1
fi
if prod_mongo_has_members; then
return 0
fi
return 1
}
prod_run_init_if_needed() {
if prod_should_skip_init; then
if [[ "${HAIXUN_SKIP_INIT:-0}" == "1" ]]; then
echo "[prod] skip init (HAIXUN_SKIP_INIT=1)"
else
echo "[prod] skip init (Mongo 已有資料;若要強制重跑請設 PROD_FORCE_INIT=1)"
fi
return 0
fi
echo "[prod] running bootstrap init..."
prod_compose --profile init run --rm init
}
prod_build_web_if_static() {
prod_common_init
if [[ "${HAIXUN_WEB_BUILD_MODE:-static}" == "static" ]]; then
echo "[prod] building frontend static files (vite → web/dist)..."
(cd "$BACKEND_DIR" && make web-build)
else
echo "[prod] HAIXUN_WEB_BUILD_MODE=docker — web image will compile inside Docker"
fi
}
prod_start_app_services() {
local build_flag=()
if [[ "${PROD_SKIP_BUILD:-0}" != "1" ]]; then
build_flag=(--build)
fi
echo "[prod] starting api, web, workers (go=${GO_REPLICAS}, node-style-8d=${NODE_REPLICAS})..."
prod_compose up -d "${build_flag[@]}" \
--no-deps \
--scale "go-worker=${GO_REPLICAS}" \
--scale "node-worker-style-8d=${NODE_REPLICAS}" \
api web go-worker node-worker-style-8d
}
prod_wait_api_health() {
echo "[prod] waiting for API health..."
for _ in $(seq 1 60); do
if curl -fsS "http://127.0.0.1:${WEB_PORT}/api/v1/health" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
echo "[prod] API health check timed out" >&2
exit 1
}
prod_print_volume_hint() {
echo " Data: Mongo/Redis 使用 named volume重啟 container 不會清資料)"
echo " Update app: make -C haixun-backend prod-update"
echo " Wipe data: make -C haixun-backend prod-wipe-data # 會刪除 volume"
}
prod_print_stack_summary() {
echo ""
echo "[prod] stack is up"
echo " Web: http://127.0.0.1:${WEB_PORT}"
echo " API: http://127.0.0.1:${WEB_PORT}/api/v1/health (via nginx)"
echo " Go worker: ${GO_REPLICAS} replica(s)"
echo " Node 8D: ${NODE_REPLICAS} replica(s)"
echo " Env: ${ENV_FILE}"
echo " Stop: make -C haixun-backend prod-down"
echo " Logs: make -C haixun-backend prod-logs"
prod_print_volume_hint
}

View File

@ -1,16 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# shellcheck source=scripts/prod-common.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/prod-common.sh"
prod_load_env
prod_require_docker
cd "$DEPLOY_DIR"
prod_ensure_deps
echo ""
echo "[prod] mongo + redis ready"
prod_print_volume_hint

View File

@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# shellcheck source=scripts/prod-common.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/prod-common.sh"
prod_common_init
cd "$DEPLOY_DIR"
prod_compose down --remove-orphans
echo "[prod] stoppedMongo/Redis 資料仍在 named volume下次 prod / prod-deps 會沿用)"

View File

@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
BACKEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.prod.yml"
cd "$BACKEND_DIR/deploy"
docker compose -f "$COMPOSE_FILE" logs -f --tail=200 "${@:-}"

View File

@ -1,23 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# shellcheck source=scripts/prod-common.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/prod-common.sh"
prod_load_env
prod_require_docker
cd "$BACKEND_DIR"
prod_build_web_if_static
cd "$DEPLOY_DIR"
echo "[prod] building images..."
prod_compose build
prod_ensure_deps
prod_run_init_if_needed
prod_start_app_services
prod_wait_api_health
prod_print_stack_summary

View File

@ -1,29 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# 只重建/重啟 API、Web、Workers不碰 mongo/redis資料留在 volume
# shellcheck source=scripts/prod-common.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/prod-common.sh"
prod_load_env
prod_require_docker
cd "$BACKEND_DIR"
prod_build_web_if_static
cd "$DEPLOY_DIR"
if ! prod_deps_healthy; then
echo "[prod] mongo/redis 未在運行,先啟動依賴(不會清 volume..."
prod_ensure_deps
else
echo "[prod] mongo + redis 維持運行 — 只更新應用層"
fi
echo "[prod] building app images (api, web, workers)..."
prod_compose build api web go-worker node-worker-style-8d
prod_start_app_services
prod_wait_api_health
prod_print_stack_summary

View File

@ -1,22 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# 危險:停止 stack 並刪除 Mongo/Redis named volume。
# shellcheck source=scripts/prod-common.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/prod-common.sh"
prod_load_env
prod_require_docker
cd "$DEPLOY_DIR"
echo "[prod] 這會刪除 haixun-prod_mongo_data 與 haixun-prod_redis_data 內所有資料。"
read -r -p "輸入 yes 才會繼續: " confirm
if [[ "$confirm" != "yes" ]]; then
echo "[prod] cancelled"
exit 1
fi
prod_compose down -v --remove-orphans
echo "[prod] volumes removed — 下次 make prod 會是全新資料庫"

View File

@ -1,7 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
BACKEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
bash "$BACKEND_DIR/scripts/stop-all.sh"
bash "$BACKEND_DIR/scripts/start-all.sh"

View File

@ -1,75 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BACKEND_DIR="$ROOT_DIR/haixun-backend"
RUN_DIR="$BACKEND_DIR/.run"
LOG_DIR="$RUN_DIR/logs"
COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.yml"
CONFIG_FILE="${HAIXUN_BACKEND_CONFIG:-etc/gateway.yaml}"
BACKEND_URL="${HAIXUN_BACKEND_URL:-http://127.0.0.1:8890}"
WEB_URL="${HAIXUN_WEB_URL:-http://127.0.0.1:5173}"
mkdir -p "$RUN_DIR" "$LOG_DIR"
bash "$BACKEND_DIR/scripts/stop-all.sh"
if ! command -v docker >/dev/null 2>&1; then
echo "[start-all] docker not found; skip mongo/redis" >&2
else
echo "[start-all] starting mongo + redis..."
docker compose -f "$COMPOSE_FILE" up -d
fi
echo "[start-all] starting Go API ($CONFIG_FILE)..."
(
cd "$BACKEND_DIR"
go run ./gateway.go -f "$CONFIG_FILE"
) >"$LOG_DIR/api.log" 2>&1 &
echo $! >"$RUN_DIR/api.pid"
echo "[start-all] waiting for API health ($BACKEND_URL)..."
for _ in $(seq 1 40); do
if curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then
break
fi
if ! kill -0 "$(cat "$RUN_DIR/api.pid")" 2>/dev/null; then
echo "[start-all] API exited early; see $LOG_DIR/api.log" >&2
tail -n 20 "$LOG_DIR/api.log" >&2 || true
exit 1
fi
sleep 1
done
if ! curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then
echo "[start-all] API health check timed out; see $LOG_DIR/api.log" >&2
exit 1
fi
if [[ ! -d "$BACKEND_DIR/web/node_modules" ]]; then
echo "[start-all] installing web dependencies..."
(cd "$BACKEND_DIR/web" && npm install)
fi
echo "[start-all] starting web dev server..."
(
cd "$BACKEND_DIR/web"
npm run dev
) >"$LOG_DIR/web.log" 2>&1 &
echo $! >"$RUN_DIR/web.pid"
echo "[start-all] starting Node 8D worker..."
(
cd "$ROOT_DIR"
npm run worker:style-8d
) >"$LOG_DIR/worker.log" 2>&1 &
echo $! >"$RUN_DIR/worker.pid"
sleep 2
echo ""
echo "[start-all] all services started"
echo " API: $BACKEND_URL"
echo " Web: $WEB_URL"
echo " Logs: $LOG_DIR/{api,web,worker}.log"
echo " Stop: make -C haixun-backend stop-all"
echo " Status: make -C haixun-backend status-all"

View File

@ -1,50 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
BACKEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUN_DIR="$BACKEND_DIR/.run"
COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.yml"
BACKEND_URL="${HAIXUN_BACKEND_URL:-http://127.0.0.1:8890}"
WEB_URL="${HAIXUN_WEB_URL:-http://127.0.0.1:5173}"
check_pid() {
local name="$1"
local file="$RUN_DIR/${name}.pid"
if [[ -f "$file" ]]; then
local pid
pid="$(cat "$file" 2>/dev/null || true)"
if [[ -n "${pid:-}" ]] && kill -0 "$pid" 2>/dev/null; then
echo " $name: running (pid=$pid)"
return 0
fi
fi
echo " $name: stopped"
return 1
}
echo "Haixun dev services"
echo ""
if command -v docker >/dev/null 2>&1 && [[ -f "$COMPOSE_FILE" ]]; then
echo "Docker:"
docker compose -f "$COMPOSE_FILE" ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || echo " (docker compose not running)"
echo ""
fi
echo "Processes:"
check_pid api || true
check_pid web || true
check_pid worker || true
echo ""
echo "Health:"
if curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then
echo " API health: OK ($BACKEND_URL)"
else
echo " API health: down ($BACKEND_URL)"
fi
if curl -fsS "$WEB_URL" >/dev/null 2>&1; then
echo " Web: OK ($WEB_URL)"
else
echo " Web: down ($WEB_URL)"
fi

View File

@ -1,51 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BACKEND_DIR="$ROOT_DIR/haixun-backend"
RUN_DIR="$BACKEND_DIR/.run"
COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.yml"
stop_pid_file() {
local name="$1"
local file="$RUN_DIR/${name}.pid"
if [[ ! -f "$file" ]]; then
return 0
fi
local pid
pid="$(cat "$file" 2>/dev/null || true)"
if [[ -n "${pid:-}" ]] && kill -0 "$pid" 2>/dev/null; then
echo "[stop-all] stopping $name (pid=$pid)"
kill "$pid" 2>/dev/null || true
for _ in $(seq 1 10); do
kill -0 "$pid" 2>/dev/null || break
sleep 0.2
done
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$file"
}
echo "[stop-all] stopping tracked processes..."
for name in worker web api; do
stop_pid_file "$name"
done
echo "[stop-all] stopping stray processes..."
pkill -f "haixun-backend/worker/style-8d-worker" 2>/dev/null || true
pkill -f "worker:style-8d" 2>/dev/null || true
pkill -f "haixun-backend/web/node_modules/.bin/vite" 2>/dev/null || true
pkill -f "go run ./gateway.go -f etc/gateway.yaml" 2>/dev/null || true
# `go run` spawns a compiled binary child under the go-build cache (e.g.
# ~/Library/Caches/go-build/.../gateway) that is NOT killed when the parent
# wrapper dies; kill the orphan too so it stops serving stale routes on the API
# port and frees the port for the freshly built binary.
pkill -f "/gateway -f etc/gateway.yaml" 2>/dev/null || true
pkill -f "dev-with-style-8d.sh" 2>/dev/null || true
if command -v docker >/dev/null 2>&1 && [[ -f "$COMPOSE_FILE" ]]; then
echo "[stop-all] stopping docker compose (mongo + redis)..."
docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true
fi
echo "[stop-all] done"

View File

@ -1,76 +0,0 @@
#!/usr/bin/env bash
# 示範 job 建立與取消running 時會透過 Redis jobs:cancel:<id> 戳 worker
#
# 用法:
# ./scripts/test-job-cancel.sh
set -u
BASE_URL="${BASE_URL:-http://127.0.0.1:8890}"
SCOPE="${SCOPE:-user}"
SCOPE_ID="${SCOPE_ID:-demo_user_1}"
require_cmd() {
command -v "$1" >/dev/null 2>&1 || { echo "missing command: $1"; exit 1; }
}
require_cmd curl
require_cmd jq
echo "== create demo_long_task =="
CREATE_BODY="$(curl -sS -X POST "${BASE_URL}/api/v1/jobs" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg scope "$SCOPE" --arg scope_id "$SCOPE_ID" '{
template_type: "demo_long_task",
scope: $scope,
scope_id: $scope_id,
payload: {target: "demo"}
}')")"
echo "$CREATE_BODY" | jq .
JOB_ID="$(echo "$CREATE_BODY" | jq -r '.data.id // empty')"
if [[ -z "$JOB_ID" ]]; then
echo "failed to create job"
exit 1
fi
echo ""
echo "== wait until running (max 10s) =="
for _ in $(seq 1 20); do
STATUS="$(curl -sS "${BASE_URL}/api/v1/jobs/${JOB_ID}" | jq -r '.data.status')"
echo "status: $STATUS"
if [[ "$STATUS" == "running" || "$STATUS" == "cancel_requested" ]]; then
break
fi
if [[ "$STATUS" == "succeeded" || "$STATUS" == "cancelled" ]]; then
echo "job finished before cancel test: $STATUS"
exit 0
fi
sleep 0.5
done
echo ""
echo "== cancel while worker is active =="
CANCEL_BODY="$(curl -sS -X POST "${BASE_URL}/api/v1/jobs/${JOB_ID}/cancel" \
-H "Content-Type: application/json" \
-d '{"reason":"demo cancel from script"}')"
echo "$CANCEL_BODY" | jq .
echo ""
echo "== poll until cancelled (max 20s) =="
for _ in $(seq 1 40); do
BODY="$(curl -sS "${BASE_URL}/api/v1/jobs/${JOB_ID}")"
STATUS="$(echo "$BODY" | jq -r '.data.status')"
PHASE="$(echo "$BODY" | jq -r '.data.phase')"
SUMMARY="$(echo "$BODY" | jq -r '.data.progress.summary')"
echo "status=$STATUS phase=$PHASE summary=$SUMMARY"
if [[ "$STATUS" == "cancelled" ]]; then
echo ""
echo "ok: worker acknowledged cancel"
exit 0
fi
sleep 0.5
done
echo "timeout waiting for cancelled status"
exit 1

View File

@ -1,87 +0,0 @@
#!/usr/bin/env bash
# Verify scheme B: same scope_id+target blocks concurrent runs; different targets allow parallel.
#
# Usage:
# ./scripts/test-job-concurrency.sh
set -u
BASE_URL="${BASE_URL:-http://127.0.0.1:8890}"
SCOPE="${SCOPE:-user}"
SCOPE_ID="${SCOPE_ID:-concurrency_demo_user}"
TEMPLATE_TYPE="${TEMPLATE_TYPE:-demo_long_task}"
TARGET_A="${TARGET_A:-building_A}"
TARGET_B="${TARGET_B:-building_B}"
require_cmd() {
command -v "$1" >/dev/null 2>&1 || { echo "missing command: $1"; exit 1; }
}
require_cmd curl
require_cmd jq
create_job() {
local target="$1"
curl -sS -X POST "${BASE_URL}/api/v1/jobs" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg scope "$SCOPE" --arg scope_id "$SCOPE_ID" --arg template "$TEMPLATE_TYPE" --arg target "$target" '{
template_type: $template,
scope: $scope,
scope_id: $scope_id,
payload: {target: $target}
}')"
}
echo "== configure template for scheme B =="
PUT_BODY="$(curl -sS -X PUT "${BASE_URL}/api/v1/job/templates/${TEMPLATE_TYPE}" \
-H "Content-Type: application/json" \
-d "$(jq -n '{
name: "Demo Long Task",
enabled: true,
repeatable: true,
concurrency_policy: "allow_parallel",
dedupe_keys: ["scope_id", "target"],
timeout_seconds: 600,
cancel_policy: {supported: true, mode: "cooperative", grace_seconds: 30},
retry_policy: {max_attempts: 2, backoff_seconds: [30, 120]},
steps: [
{id: "prepare", name: "Prepare", worker_type: "go", timeout_seconds: 60, cancelable: true},
{id: "execute", name: "Execute", worker_type: "go", timeout_seconds: 300, cancelable: true},
{id: "finalize", name: "Finalize", worker_type: "go", timeout_seconds: 30, cancelable: false}
]
}')")"
echo "$PUT_BODY" | jq .
if [[ "$(echo "$PUT_BODY" | jq -r '.code')" != "102000" ]]; then
echo "failed to upsert template"
exit 1
fi
echo ""
echo "== same target: first create should succeed =="
FIRST="$(create_job "$TARGET_A")"
echo "$FIRST" | jq .
if [[ "$(echo "$FIRST" | jq -r '.code')" != "102000" ]]; then
echo "first create failed"
exit 1
fi
echo ""
echo "== same target: second create should fail while first is active =="
SECOND="$(create_job "$TARGET_A")"
echo "$SECOND" | jq .
if [[ "$(echo "$SECOND" | jq -r '.code')" == "102000" ]]; then
echo "expected duplicate create to fail"
exit 1
fi
echo ""
echo "== different target: should succeed in parallel =="
THIRD="$(create_job "$TARGET_B")"
echo "$THIRD" | jq .
if [[ "$(echo "$THIRD" | jq -r '.code')" != "102000" ]]; then
echo "different target create failed"
exit 1
fi
echo ""
echo "ok: scheme B concurrency behaves as expected"