diff --git a/.gitignore b/.gitignore index dee73b2..bfcb463 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ coverage/ # SDD 工作区(ledger / brief / 评审包)—— 记录归 git 历史,过程件不入库 .superpowers/ + +# 稳定版快照与运行态(./stable.sh 写的) +.stable/ diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 698c158..dde37c1 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -5,7 +5,31 @@ import { fileURLToPath } from 'node:url'; const root = fileURLToPath(new URL('../..', import.meta.url)); const p = (rel: string) => fileURLToPath(new URL(rel, import.meta.url)); +/* + * 稳定版(./stable.sh)用 `vite preview` 托 .build/stable 的静态文件。preview 有一道宿主校验 + * (防 DNS rebinding),默认只放 localhost 与 IP —— 经域名 / 反代进来会被拦: + * 「Blocked request. This host ("…") is not allowed.」 + * 放哪些宿主由起它的人说(PREVIEW_ALLOWED_HOSTS,逗号分隔;`.example.com` 放整个子域;`*` 全放)。 + * 开发那台 `vite` 不读这个,不受影响。 + */ +const previewHosts = process.env.PREVIEW_ALLOWED_HOSTS?.trim(); +const preview = + previewHosts === undefined || previewHosts === '' + ? {} + : { + preview: { + allowedHosts: + previewHosts === '*' + ? (true as const) + : previewHosts + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== ''), + }, + }; + export default defineConfig({ + ...preview, plugins: [react()], resolve: { alias: { diff --git a/stable.sh b/stable.sh new file mode 100755 index 0000000..2e9a86d --- /dev/null +++ b/stable.sh @@ -0,0 +1,366 @@ +#!/bin/bash +# 稳定版 —— 把**当前代码**构建成一份快照,在另一组端口上常驻,给外部访问。 +# 与 ./start.sh 起的开发服务(:8787 / :5173,tsup --watch + vite HMR)互不相干: +# 开发那边改代码、重启、崩掉,这边一个字都不动;要发新版,显式跑一次 update。 +# +# ./stable.sh 起(没有快照就先构建;已在跑的不动) +# ./stable.sh update 用当前代码重建快照,然后重启两个 —— 这才是「发一版」 +# ./stable.sh restart 不重建,只重启 +# ./stable.sh stop 停掉两个 +# ./stable.sh status 只看状态,一个进程都不动 +# ./stable.sh logs [api|web] 看日志(tail -f) +# ./stable.sh env-from-dev 把开发服务(:8787)进程里的环境变量抄成 .stable/env +# +# 落盘位置(全部 gitignore): +# apps/api/.build/stable/ 后端快照(tsup 一次性构建,不 watch)—— 必须住在 apps/api 下, +# 产物里 `import "hono"` 这类裸名要从 apps/api/node_modules 解析 +# apps/web/.build/stable/ 前端快照(vite build),由 vite preview 托静态 + /api 反代到稳定版后端 +# .stable/env 环境变量(KEY=VALUE,600 权限)。缺它起不来 —— Keycloak 三样是硬门 +# .stable/logs/ api.log / web.log / build.log +# .stable/RELEASE 这份快照是哪个 commit、什么时候构的 +# +# .stable/env 长这样(env-from-dev 会自动照开发进程抄一份;要单独一套自己改): +# KEYCLOAK_URL=https://… KEYCLOAK_REALM=corp KEYCLOAK_CLIENT_ID=task-dispatcher +# KEYCLOAK_PROVISIONING_CLIENT_ID=… KEYCLOAK_PROVISIONING_CLIENT_SECRET=…(可选,名册同步) +# JCAI_TEST_ACCOUNTS=… JCAI_SECRET_KEY=…(开发没设的话这里也别设 —— 库里的密文 +# 是用哪把钥匙封的就得用哪把开) +# DATABASE_URL=… (不填 = 与开发共用一个库,见下) +# STABLE_API_PORT=8790 STABLE_WEB_PORT=5180 +# STABLE_ALLOWED_HOSTS=jcai-bpos.akhr-ai.com 经域名/反代访问时要列在这儿(逗号分隔,`*` 全放)—— +# vite preview 有宿主校验,不列会 403「This host is not allowed」 +# +# ./stable.sh stop web / restart api —— 第二个参数可只动一个 +# +# 关于数据库:默认与开发共用同一个库(两台后端连一个库是本仓库既有的跑法,跨进程租约与 +# LISTEN/NOTIFY 桥都为此做过)。代价要知道:开发那边 `reset-runs` 会把稳定版上外部人跑出来的 +# 任务一起清掉;开发那边跑了新迁移,稳定版的旧代码就在改过的表结构上跑。真要隔离, +# 在 .stable/env 里把 DATABASE_URL 指到另一个库,并自己跑一遍迁移 + 播种。 +# +# 沿用 start.sh 踩过的坑:端口属主只认 LISTEN(不然会把浏览器的连接当成占用者); +# 起完轮询到 /api/health 的 body 里 db:"up" 才算数(它在库挂了时照样返 200)。 +set -u +cd "$(dirname "$0")" || exit 1 +REPO=$(pwd) +STABLE="$REPO/.stable" +ENV_FILE="$STABLE/env" +LOGDIR="$STABLE/logs" +API_DIR="$REPO/apps/api" +WEB_DIR="$REPO/apps/web" +API_OUT="$API_DIR/.build/stable" +WEB_OUT="$WEB_DIR/.build/stable" +DEV_API_PORT=8787 +mkdir -p "$LOGDIR" +WHAT=${1:-start} + +say() { printf '%s\n' "$*"; } +die() { say " ✗ $*"; exit 1; } + +# ── 环境 ──────────────────────────────────────────────────────────────── +ENV_KEYS='KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_CLIENT_ID KEYCLOAK_PROVISIONING_CLIENT_ID KEYCLOAK_PROVISIONING_CLIENT_SECRET JCAI_TEST_ACCOUNTS JCAI_SECRET_KEY JCAI_DEEPSEEK_API_KEY JCAI_LLM_API_KEY JCAI_LLM_BASE_URL DATABASE_URL JCAI_ATTACHMENT_ROOT JCAI_DISPATCH_INTERVAL_MS JCAI_MAIL_POLL_TICK_MS NODE_ENV' + +# 端口的真属主 —— 只认 LISTEN +owner_of() { lsof -ti:"$1" -sTCP:LISTEN 2>/dev/null | head -1; } + +env_from_dev() { + local pid envs k v n=0 + pid=$(owner_of "$DEV_API_PORT") + [ -n "$pid" ] || die "开发后端(:$DEV_API_PORT)没在跑,抄不到环境 —— 先 ./start.sh api,或照文件头的模版手写 $ENV_FILE" + # macOS 的 ps -E 把环境变量追在命令后面,空格分隔;这几个键的值都不含空格 + envs=$(ps -E -p "$pid" -o command= 2>/dev/null | tr ' ' '\n') + [ -n "$envs" ] || die "读不到 pid $pid 的环境(不是同一个用户起的?)" + : > "$ENV_FILE" + chmod 600 "$ENV_FILE" + for k in $ENV_KEYS; do + v=$(printf '%s\n' "$envs" | grep -m1 "^$k=" | cut -d= -f2-) + if [ -n "$v" ]; then + printf '%s=%s\n' "$k" "$v" >> "$ENV_FILE" + n=$((n + 1)) + fi + done + printf '%s=%s\n%s=%s\n%s=%s\n' STABLE_API_PORT "${STABLE_API_PORT:-8790}" STABLE_WEB_PORT "${STABLE_WEB_PORT:-5180}" \ + STABLE_ALLOWED_HOSTS "${STABLE_ALLOWED_HOSTS:-}" >> "$ENV_FILE" + say " ✓ 从开发后端(pid $pid)抄了 $n 个变量 → $ENV_FILE(600)" + say " $(cut -d= -f1 "$ENV_FILE" | tr '\n' ' ')" +} + +load_env() { + if [ ! -f "$ENV_FILE" ]; then + say " $ENV_FILE 还没有,先照开发后端抄一份:" + env_from_dev + fi + set -a + # shellcheck disable=SC1090 + . "$ENV_FILE" + set +a + API_PORT=${STABLE_API_PORT:-8790} + WEB_PORT=${STABLE_WEB_PORT:-5180} + for k in KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_CLIENT_ID; do + eval "v=\${$k:-}" + # shellcheck disable=SC2154 + [ -n "$v" ] || die "$ENV_FILE 里缺 $k —— 统一鉴权中心是唯一的登录途径,缺了后端拒绝启动(ADR-012)" + done +} + +# ── 构建快照 ──────────────────────────────────────────────────────────── +build() { + local sha dirty at + say '构建快照:' + say " api → apps/api/.build/stable …" + # --out-dir 盖过 tsup.config 的 outDir;config 里的 clean:true 清的就是这个目录,碰不到开发的 dist/ + if ! (cd "$API_DIR" && pnpm exec tsup --out-dir .build/stable) > "$LOGDIR/build.log" 2>&1; then + tail -20 "$LOGDIR/build.log" + die 'api 构建失败(全文 .stable/logs/build.log)' + fi + [ -f "$API_OUT/main.js" ] || die "构建说成功,可 $API_OUT/main.js 不在" + say " web → apps/web/.build/stable …" + if ! (cd "$WEB_DIR" && pnpm exec vite build --outDir .build/stable --emptyOutDir) >> "$LOGDIR/build.log" 2>&1; then + tail -20 "$LOGDIR/build.log" + die 'web 构建失败(全文 .stable/logs/build.log)' + fi + [ -f "$WEB_OUT/index.html" ] || die "构建说成功,可 $WEB_OUT/index.html 不在" + sha=$(git rev-parse --short HEAD 2>/dev/null || echo unknown) + dirty=$([ -n "$(git status --porcelain 2>/dev/null)" ] && echo '+未提交改动' || echo '') + at=$(date '+%Y-%m-%d %H:%M:%S') + printf 'commit=%s%s\nbuilt_at=%s\n' "$sha" "$dirty" "$at" > "$STABLE/RELEASE" + say " ✓ 快照:$sha$dirty · $at" +} + +have_snapshot() { [ -f "$API_OUT/main.js" ] && [ -f "$WEB_OUT/index.html" ]; } + +# ── 起停 ──────────────────────────────────────────────────────────────── +# 直接 exec node,进程链上不留 pnpm/bash:pid 文件里记的就是真正听端口的那个进程 +start_api() { + if [ -n "$(owner_of "$API_PORT")" ]; then + say " api :$API_PORT 已在跑,不动它" + return 0 + fi + # cwd 必须是 apps/api:附件根目录默认 cwd/.data/attachments,与开发服务同一处 —— + # 共用一个库时,库里存的 file:// 指针两边都得指得到 + ( cd "$API_DIR" && PORT="$API_PORT" exec nohup node .build/stable/main.js < /dev/null >> "$LOGDIR/api.log" 2>&1 ) & + echo $! > "$STABLE/api.pid" + disown 2>/dev/null + say " api :$API_PORT 启动中… (pid $(cat "$STABLE/api.pid"),日志 .stable/logs/api.log)" +} + +start_web() { + if [ -n "$(owner_of "$WEB_PORT")" ]; then + say " web :$WEB_PORT 已在跑,不动它" + return 0 + fi + # vite preview:托 .build/stable 的静态文件,/api 反代沿用 vite.config 的 server.proxy(preview 默认继承), + # 目标由 API_PROXY_TARGET 指到稳定版后端;--host 绑全部网卡,外部才连得进来 + ( cd "$WEB_DIR" && API_PROXY_TARGET="http://127.0.0.1:$API_PORT" PREVIEW_ALLOWED_HOSTS="${STABLE_ALLOWED_HOSTS:-}" \ + exec nohup node node_modules/vite/bin/vite.js preview \ + --outDir .build/stable --host --port "$WEB_PORT" --strictPort < /dev/null >> "$LOGDIR/web.log" 2>&1 ) & + echo $! > "$STABLE/web.pid" + disown 2>/dev/null + say " web :$WEB_PORT 启动中… (pid $(cat "$STABLE/web.pid"),日志 .stable/logs/web.log)" +} + +# 只杀「确实是我们起的那个」:pid 文件里的 pid 还活着且命令里带 .build/stable,或端口属主命令里带 .build/stable +# (dist-stable 是 08-26 之前的旧落点,留着认一阵子,免得换目录那一刻停不掉老进程) +is_ours() { ps -o command= -p "$1" 2>/dev/null | grep -qE '\.build/stable|dist-stable'; } + +stop_one() { + local label=$1 port=$2 pidfile=$3 pid owner i + pid=$(cat "$pidfile" 2>/dev/null || true) + owner=$(owner_of "$port") + if [ -z "$owner" ] && { [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; }; then + say " $label 本来就没在跑" + rm -f "$pidfile" + return 0 + fi + # pid 文件里的与端口属主多半是同一个进程,别对它说两遍话 + [ "$owner" = "$pid" ] && owner='' + for i in $pid $owner; do + if is_ours "$i"; then + kill "$i" 2>/dev/null + elif [ -n "$(ps -o command= -p "$i" 2>/dev/null)" ]; then + say " ! :$port 的属主 pid $i 不是稳定版的进程,不动它:$(ps -o command= -p "$i" | cut -c1-80)" + fi + done + for i in 1 2 3 4 5 6 7 8; do + [ -z "$(owner_of "$port")" ] && { say " $label 已停"; rm -f "$pidfile"; return 0; } + sleep 1 + done + owner=$(owner_of "$port") + if is_ours "$owner"; then + kill -9 "$owner" 2>/dev/null + sleep 1 + [ -z "$(owner_of "$port")" ] && { say " $label 已停(-9)"; rm -f "$pidfile"; return 0; } + fi + say " ! $label :$port 还被占着 —— 手工看看 lsof -i:$port" + return 1 +} + +# ── 核实 ──────────────────────────────────────────────────────────────── +http_code() { curl -s -o /dev/null -m 3 -w '%{http_code}' "$1" 2>/dev/null; } + +# /api/health 在库挂了时照样返 200,得看 body 里的 db +wait_api() { + local i body + for i in $(seq 1 45); do + body=$(curl -s -m 3 "http://127.0.0.1:$API_PORT/api/health" 2>/dev/null) + case "$body" in + *'"db":"up"'*) say " ✓ api 就绪 (${i}s) $body"; return 0 ;; + *'"db":"down"'*) say " ✗ api 起来了但连不上库:$body"; return 1 ;; + esac + # 进程已经死了就别等满 45 秒 + if [ -f "$STABLE/api.pid" ] && ! kill -0 "$(cat "$STABLE/api.pid")" 2>/dev/null; then + say ' ✗ api 进程已退出 —— 日志末尾:' + tail -5 "$LOGDIR/api.log" | sed 's/^/ /' + return 1 + fi + sleep 1 + done + say ' ✗ api 45 秒没有应答 —— 看 .stable/logs/api.log' + return 1 +} + +wait_web() { + local i code + for i in $(seq 1 30); do + code=$(http_code "http://127.0.0.1:$WEB_PORT/") + [ "$code" = '200' ] && { say " ✓ web 就绪 (${i}s)"; return 0; } + if [ -f "$STABLE/web.pid" ] && ! kill -0 "$(cat "$STABLE/web.pid")" 2>/dev/null; then + say ' ✗ web 进程已退出 —— 日志末尾:' + tail -5 "$LOGDIR/web.log" | sed 's/^/ /' + return 1 + fi + sleep 1 + done + say ' ✗ web 30 秒没有应答 —— 看 .stable/logs/web.log' + return 1 +} + +verify() { + local rc=0 proxy spa + wait_api || rc=1 + wait_web || rc=1 + proxy=$(http_code "http://127.0.0.1:$WEB_PORT/api/health") + [ "$proxy" = '200' ] && say ' ✓ 反代 /api → 200' || { say " ✗ 反代 /api → ${proxy:-连不上}"; rc=1; } + # 三个台面是同一个 SPA,刷新 /design /ops 要吐 index.html + spa=$(http_code "http://127.0.0.1:$WEB_PORT/design") + [ "$spa" = '200' ] && say ' ✓ SPA 回退 /design → 200' || { say " ✗ SPA 回退 /design → ${spa:-连不上}"; rc=1; } + return $rc +} + +lan_ip() { ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || true; } + +# 登录能不能跳回来,Keycloak 那一侧说了算:web-portal 客户端没登记这个来源的 redirect URI, +# 页面开得了、点「前往统一登录」就吃一句 Invalid parameter: redirect_uri。 +# 服务起好了不等于能用 —— 这里替人先问一次 Keycloak(只看它对 redirect_uri 的裁决,不登录)。 +WEB_CLIENT_ID=$(grep -m1 '^VITE_KEYCLOAK_CLIENT_ID=' "$WEB_DIR/.env.production" 2>/dev/null | cut -d= -f2-) +kc_redirect_ok() { + local origin=$1 enc body + enc=$(printf '%s/' "$origin" | sed 's,/,%2F,g; s,:,%3A,g') + body=$(curl -s -m 10 "$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/auth?client_id=${WEB_CLIENT_ID:-web-portal}&response_type=code&scope=openid&redirect_uri=$enc&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256" 2>/dev/null) + case "$body" in + '') return 2 ;; # Keycloak 连不上,说不出 + *'Invalid parameter: redirect_uri'*) return 1 ;; # 明确拒了 + *) return 0 ;; + esac +} + +# 列进 STABLE_ALLOWED_HOSTS 的域名,外部多半是 https 进来的,一并问 Keycloak(通配与整子域问不了,跳过) +allowed_origins() { + local h + for h in $(printf '%s' "${STABLE_ALLOWED_HOSTS:-}" | tr ',' ' '); do + case "$h" in '*' | .*) ;; *) printf 'https://%s\n' "$h" ;; esac + done +} + +report_kc() { + local origin + set -- "$@" $(allowed_origins) + for origin in "$@"; do + kc_redirect_ok "$origin" + case $? in + 0) say " ✓ Keycloak 接受 $origin 的 redirect_uri" ;; + 1) say " ✗ Keycloak 拒绝 $origin 的 redirect_uri —— 去 realm $KEYCLOAK_REALM 的客户端 ${WEB_CLIENT_ID:-web-portal} 把 $origin/* 加进 Valid redirect URIs 与 Web origins,不然这个入口登不进" ;; + *) say " ? Keycloak($KEYCLOAK_URL)没应答,登录能不能跳回来说不准" ;; + esac + done +} + +finish() { + local ip + say '' + if [ "$1" = '0' ]; then + ip=$(lan_ip) + say " 稳定版都好了 → http://localhost:$WEB_PORT/" + [ -n "$ip" ] && say " 局域网 → http://$ip:$WEB_PORT/" + report_kc "http://localhost:$WEB_PORT" ${ip:+"http://$ip:$WEB_PORT"} + else + say ' 有起不来的,日志在 .stable/logs/' + fi +} + +status() { + local ip + say '稳定版状态:' + [ -f "$STABLE/RELEASE" ] && sed 's/^/ 快照 /' "$STABLE/RELEASE" || say ' 快照 (还没构建过)' + say " api :$API_PORT $(http_code "http://127.0.0.1:$API_PORT/api/health") $(curl -s -m 3 "http://127.0.0.1:$API_PORT/api/health" 2>/dev/null) pid $(owner_of "$API_PORT")" + say " web :$WEB_PORT $(http_code "http://127.0.0.1:$WEB_PORT/") pid $(owner_of "$WEB_PORT")" + say " 反代 /api $(http_code "http://127.0.0.1:$WEB_PORT/api/health")" + ip=$(lan_ip) + [ -n "$ip" ] && say " 入口 http://localhost:$WEB_PORT/ · http://$ip:$WEB_PORT/" + report_kc "http://localhost:$WEB_PORT" ${ip:+"http://$ip:$WEB_PORT"} +} + +# ── 分派 ──────────────────────────────────────────────────────────────── +case "$WHAT" in + env-from-dev) + env_from_dev + exit 0 + ;; + logs) + exec tail -n 50 -f "$LOGDIR/${2:-api}.log" + ;; + status) + load_env + status + exit 0 + ;; + stop) + load_env + say '停止稳定版:' + [ "${2:-}" != 'web' ] && stop_one api "$API_PORT" "$STABLE/api.pid" + [ "${2:-}" != 'api' ] && stop_one web "$WEB_PORT" "$STABLE/web.pid" + exit 0 + ;; + start) + load_env + have_snapshot || build + say "起稳定版(api :$API_PORT · web :$WEB_PORT):" + start_api + start_web + ;; + update) + load_env + build + say '重启稳定版:' + stop_one api "$API_PORT" "$STABLE/api.pid" + stop_one web "$WEB_PORT" "$STABLE/web.pid" + start_api + start_web + ;; + restart) + load_env + have_snapshot || die '还没有快照,先 ./stable.sh update' + say "重启稳定版${2:+($2)}:" + [ "${2:-}" != 'web' ] && stop_one api "$API_PORT" "$STABLE/api.pid" + [ "${2:-}" != 'api' ] && stop_one web "$WEB_PORT" "$STABLE/web.pid" + start_api + start_web + ;; + *) + say "不认识的参数:$WHAT(可用:start / update / restart / stop / status / logs [api|web] / env-from-dev)" + exit 2 + ;; +esac + +RC=0 +verify || RC=1 +finish $RC +exit $RC