Commit 7684e3c4 by luoqi

Merge production readiness tooling into test

parents 3cd4acf2 65a0f90e
Pipeline #3735 failed in 0 seconds
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
export function queueHealthy(stats) {
return (
Number.isFinite(stats.oldestSeconds) &&
stats.oldestSeconds <= 300 &&
Number.isInteger(stats.expiredLeases ?? 0) &&
(stats.expiredLeases ?? 0) === 0
);
}
export async function probeJson(url, expected, headers = {}) {
const response = await fetch(url, { headers, signal: AbortSignal.timeout(5000) });
if (!response.ok) throw Error('http');
const body = await response.json();
if (!expected(body)) throw Error('body');
return body;
}
export async function main() {
const checks = {};
const run = async (name, fn) => {
try {
checks[name] = { ok: true, ...(await fn()) };
} catch {
checks[name] = { ok: false };
}
};
await run('ai', async () => {
await probeJson(
'http://127.0.0.1:3104/health/ready',
(b) => b.status === 'ok' && b.db === 'up',
);
});
await run('web', async () => {
const r = await fetch('http://ai-web:3103/login', { signal: AbortSignal.timeout(5000) });
if (!r.ok) throw Error('web');
});
await run('pac', async () => {
await probeJson(process.env.AI_PAC_BASE_URL + '/health', (b) => b.status === 'ok');
});
await run('ingress', async () => {
const stats = await probeJson(
'http://channel-ingress:3106/status',
(b) => Number.isInteger(b.pending) && Number.isFinite(b.oldestSeconds),
{ Authorization: 'Bearer ' + process.env.AI_INGRESS_TOKEN },
);
return {
ok: queueHealthy(stats),
pending: stats.pending,
oldestSeconds: Math.round(stats.oldestSeconds),
retrying: stats.retrying,
};
});
await run('outbox', async () => {
const require = createRequire(`${process.cwd()}/package.json`);
const { PrismaClient } = require('./generated/prisma');
const url = new URL(process.env.AI_DATABASE_URL);
url.searchParams.set('connection_limit', '1');
url.searchParams.set('connect_timeout', '5');
const db = new PrismaClient({ datasources: { db: { url: url.toString() } } });
try {
await db.$executeRawUnsafe("SET statement_timeout='5s'");
// 只查已有部分索引中的待处理记录,未来预约排程不算积压。
const [s] = await db.$queryRawUnsafe(`SELECT
(SELECT count(*)::int FROM app.outbox WHERE status='pending' AND scheduled_at<=now()) AS pending,
COALESCE((SELECT extract(epoch FROM now()-min(scheduled_at))::float8 FROM app.outbox WHERE status='pending' AND scheduled_at<=now()),0) AS "oldestSeconds",
(SELECT count(*)::int FROM app.outbox WHERE status='processing' AND lease_until<now()-interval '1 minute') AS "expiredLeases"`);
return { ...s, ok: queueHealthy(s), oldestSeconds: Math.round(s.oldestSeconds) };
} finally {
await db.$disconnect();
}
});
const report = {
event: 'ai_ops_health',
at: new Date().toISOString(),
status: Object.values(checks).every((c) => c.ok) ? 'ok' : 'degraded',
checks,
};
console.log(JSON.stringify(report));
return report.status === 'ok' ? 0 : 1;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url))
process.exitCode = await main();
#!/usr/bin/env bash
# 应用机单次业务巡检;由 cron/运维监控调用,不依赖开发电脑或 Codex。
# 不自动重启、不重试消息;退出 0=正常,1=异常。所有输出均不含凭据、患者信息。
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
command -v timeout >/dev/null || { echo '{"event":"ai_ops_health","status":"degraded","reason":"timeout_command_missing"}'; exit 1; }
if ! output=$(timeout 45s docker exec -i pac-ai-ai-service-1 node --input-type=module < <(cat deploy/ai-health.mjs; echo 'process.exitCode=await main();') 2>/dev/null); then
if [[ "$output" == *'"event":"ai_ops_health"'* ]]; then printf '%s\n' "$output";
else echo '{"event":"ai_ops_health","status":"degraded","reason":"probe_unavailable"}'; fi
exit 1
fi
printf '%s\n' "$output"
......@@ -24,6 +24,7 @@ main() {
docker network inspect pac_default >/dev/null
export AI_IMAGE_TAG="$(git rev-parse --short HEAD)-$(date -u +%Y%m%dT%H%M%SZ)"
local compose=(docker compose -p pac-ai -f docker-compose.ai.yml)
[[ "${COMPOSE_MANAGED:-0}" != 1 ]] || compose+=(-f docker-compose.ai.managed.yml)
"${compose[@]}" config --quiet
# 先构建、预检;失败时旧容器继续运行。串行构建,限制对同机 PAC 的影响。
"${compose[@]}" build ai-service
......@@ -51,7 +52,7 @@ main() {
actual=$(docker inspect "$id" --format '{{.Image}}')
[[ "$actual" == "$expected" ]] || { echo "$service 镜像校验失败"; return 1; }
done
"${compose[@]}" exec -T ai-service node -e "fetch(process.env.AI_PAC_BASE_URL+'/health',{signal:AbortSignal.timeout(5000)}).then(async r=>{const v=await r.json();if(!r.ok||v.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))"
"${compose[@]}" exec -T --interactive=false ai-service node -e "fetch(process.env.AI_PAC_BASE_URL+'/health',{signal:AbortSignal.timeout(5000)}).then(async r=>{const v=await r.json();if(!r.ok||v.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))"
printf '%s\n' "$AI_IMAGE_TAG" > "$state/current-image-tag"
echo "FridayAI 部署完成:${AI_IMAGE_TAG};Web 3103 / API 3104;PAC 未重启"
}
......
......@@ -2,7 +2,9 @@
# 独立发布中转;日常 AI 部署不调用这个脚本。
set -euo pipefail
target=${1:-}
[[ "$target" == staging || "$target" == production ]] || { echo '用法:bash deploy/deploy-ingress.sh staging|production'; exit 1; }
option=${2:-}
[[ -z "$option" || "$option" == --check ]] || { echo '未知选项'; exit 1; }
[[ "$target" == staging || "$target" == production ]] || { echo '用法:bash deploy/deploy-ingress.sh staging|production [--check]'; exit 1; }
cd "$(dirname "${BASH_SOURCE[0]}")/.."
branch=test
[[ "$target" == production ]] && branch=main
......@@ -15,9 +17,11 @@ untracked=$(git ls-files --others --exclude-standard | grep -vE '(^|/)\.env([.-]
docker network inspect pac_default >/dev/null
export INGRESS_IMAGE_TAG="$(git rev-parse --short HEAD)-$(date -u +%Y%m%dT%H%M%SZ)"
compose=(docker compose -p pac-ingress -f docker-compose.ingress.yml)
[[ "${COMPOSE_MANAGED:-0}" != 1 ]] || compose+=(-f docker-compose.ingress.managed.yml)
"${compose[@]}" config --quiet
"${compose[@]}" build channel-ingress
"${compose[@]}" run --rm --no-deps -T --interactive=false channel-ingress node -e "try{require('./dist/config').readConfig()}catch{console.error('中转配置无效');process.exit(1)}"
"${compose[@]}" run --rm --no-deps -T --interactive=false channel-ingress node -e "try{const c=require('./dist/config').readConfig();if('$target'==='production'&&(c.localAccounts.length||c.localToken))throw Error('production local routing')}catch{console.error('中转配置无效');process.exit(1)}"
if [[ "$option" == --check ]]; then echo "中转构建与配置检查通过:${INGRESS_IMAGE_TAG}(未迁移或重启)"; exit 0; fi
# 独立 schema 的幂等 DDL;不停止 AI,不执行 AI 的迁移。
"${compose[@]}" run --rm --no-deps -T --interactive=false ingress-migrate
# 切换配置前先检查积压归属;失败时旧中转保持运行。
......@@ -29,4 +33,4 @@ actual=$(docker inspect "$id" --format '{{.Image}}')
[[ "$expected" == "$actual" ]] || { echo '中转镜像校验失败'; exit 1; }
mkdir -p "$(git rev-parse --git-common-dir)/ingress-deploy"
printf '%s\n' "$INGRESS_IMAGE_TAG" > "$(git rev-parse --git-common-dir)/ingress-deploy/current-image-tag"
echo "中转部署完成:$INGRESS_IMAGE_TAG;3106;未修改 Nginx、未停止旧调试中转"
echo "中转部署完成:${INGRESS_IMAGE_TAG};3106;未修改 Nginx、未停止旧调试中转"
......@@ -21,6 +21,8 @@
#
# 环境变量:
# WATCH_URL 被监控服务的 base(默认 https://pac.friday.tech),脚本自己拼 /health
# WATCH_PATH 默认 /health;FridayAI 使用 /health/ready
# WATCH_REQUIRE_DB FridayAI 设 1,同时要求 db=up
# WATCH_NAME 告警里显示的名字 + 状态文件名(默认 pac-prod)
# ALERT_WEBHOOK_URL 企微群机器人 webhook。⛔ 不设 = 只写日志不推送(等于没装)
# WATCH_FAIL_THRESHOLD 连续失败几次才告警(默认 3)。× cron 间隔 = 真实容忍时长
......@@ -52,6 +54,8 @@ set -uo pipefail # ⛔ 不加 -e:探测失败是正常业务路径,不是脚
WATCH_URL="${WATCH_URL:-https://pac.friday.tech}"
WATCH_NAME="${WATCH_NAME:-pac-prod}"
WATCH_PATH="${WATCH_PATH:-/health}"
WATCH_REQUIRE_DB="${WATCH_REQUIRE_DB:-0}"
WATCH_FAIL_THRESHOLD="${WATCH_FAIL_THRESHOLD:-3}"
WATCH_REPEAT_EVERY="${WATCH_REPEAT_EVERY:-12}"
WATCH_TIMEOUT="${WATCH_TIMEOUT:-10}"
......@@ -86,12 +90,13 @@ fi
# 返回 0 = 健康。stdout 是给人看的诊断串(状态码 + body 截断)。
probe() {
local base="$1" out code body
out=$(curl -sS -m "$WATCH_TIMEOUT" -w $'\n%{http_code}' "$base/health" 2>&1) || out=$'\n000'
out=$(curl -sS -m "$WATCH_TIMEOUT" -w $'\n%{http_code}' "$base$WATCH_PATH" 2>&1) || out=$'\n000'
code=${out##*$'\n'}
body=${out%$'\n'*}
body=${body//$'\n'/ }
printf 'http=%s body=%s' "$code" "${body:0:200}"
[[ "$code" == "200" && "$body" == *'"status":"ok"'* ]]
[[ "$code" == "200" && "$body" == *'"status":"ok"'* ]] &&
{ [[ "$WATCH_REQUIRE_DB" != 1 ]] || [[ "$body" == *'"db":"up"'* ]]; }
}
# ── 状态读写 ───────────────────────────────────────────────────────────
......@@ -124,8 +129,8 @@ json_escape() {
push() { # $1=icon $2=color $3=标题 $4=正文
local content resp payload
content=$(printf '%s **<font color="%s">[PAC 外部探针] %s</font>**\n%s\n> 目标: %s/health\n> 探针机: %s\n> time: %s' \
"$1" "$2" "$3" "$4" "$WATCH_URL" "$(hostname)" "$(ts)")
content=$(printf '%s **<font color="%s">[PAC 外部探针] %s</font>**\n%s\n> 目标: %s\n> 探针机: %s\n> time: %s' \
"$1" "$2" "$3" "$4" "$WATCH_URL$WATCH_PATH" "$(hostname)" "$(ts)")
if [[ "$WATCH_DRY_RUN" == "1" ]]; then
printf '%s [%s] [DRY-RUN 不推送] ↓↓↓\n%s\n' "$(ts)" "$WATCH_NAME" "$content"
return 0
......
#!/usr/bin/env node
// 在 PAC 运行容器内执行;不改应用版本,不代替 Prisma 迁移登记。
const { createRequire } = require('node:module');
const { setTimeout: delay } = require('node:timers/promises');
const requireApp = createRequire(`${process.cwd()}/package.json`);
const indexes = [
[
'patient_transactions_patient_id_clinic_id_idx',
'patient_transactions',
['patient_id', 'clinic_id'],
],
[
'patient_facts_host_id_tenant_id_type_occurred_at_idx',
'patient_facts',
['host_id', 'tenant_id', 'type', 'occurred_at'],
],
[
'patient_facts_host_id_tenant_id_type_planned_for_idx',
'patient_facts',
['host_id', 'tenant_id', 'type', 'planned_for'],
],
];
function validIndex(row, table, columns) {
return (
row &&
row.indisvalid &&
row.indisready &&
row.table_name === table &&
row.method === 'btree' &&
!row.predicate &&
!row.expressions &&
!row.indisunique &&
JSON.stringify(row.columns) === JSON.stringify(columns)
);
}
module.exports = { indexes, validIndex };
async function main() {
const apply = process.argv.includes('--apply');
const { PrismaClient } = requireApp('@prisma/client');
const url = new URL(process.env.DATABASE_URL);
url.searchParams.set('connection_limit', '1');
url.searchParams.set('application_name', 'pac-index-preparation');
const db = new PrismaClient({ datasources: { db: { url: url.toString() } } });
const observer = new PrismaClient({ datasources: { db: { url: url.toString() } } });
const log = (event, data = {}) =>
console.log(
JSON.stringify({ at: new Date().toISOString(), event, ...data }, (_, v) =>
typeof v === 'bigint' ? String(v) : v,
),
);
const inspect = async (name) =>
(
await db.$queryRawUnsafe(
`
SELECT i.indisvalid,i.indisready,i.indisunique,t.relname AS table_name,a.amname AS method,
pg_get_expr(i.indpred,i.indrelid) AS predicate,pg_get_expr(i.indexprs,i.indrelid) AS expressions,
ARRAY(SELECT pg_get_indexdef(i.indexrelid,k,true) FROM generate_series(1,i.indnatts) k) AS columns,
pg_size_pretty(pg_relation_size(c.oid)) AS size
FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace
JOIN pg_index i ON i.indexrelid=c.oid JOIN pg_class t ON t.oid=i.indrelid
JOIN pg_am a ON a.oid=c.relam
WHERE n.nspname='public' AND c.relname=$1`,
name,
)
)[0];
const busy = async () => {
// 摄入在 ClickHouse 查询/JS 解析期间,PostgreSQL 可能完全空闲;不能只看 pg_stat_activity。
const [sync] = await observer.$queryRawUnsafe(
"SELECT EXISTS(SELECT 1 FROM sync_logs WHERE status='running' OR ended_at>now()-interval '15 minutes') AS busy",
);
if (sync.busy) return true;
try {
const response = await fetch('http://127.0.0.1:3101/health', {
signal: AbortSignal.timeout(1000),
});
if (!response.ok || (await response.json()).status !== 'ok') return true;
} catch {
return true;
}
const [r] = await observer.$queryRawUnsafe(`SELECT count(*)::int AS count FROM pg_stat_activity
WHERE datname=current_database() AND application_name <> 'pac-index-preparation'
AND backend_type='client backend' AND
((state='active' AND query_start<now()-interval '5 seconds') OR xact_start<now()-interval '1 minute')`);
const builds = await observer.$queryRawUnsafe('SELECT pid FROM pg_stat_progress_create_index');
return r.count > 0 || builds.length > 0;
};
let locked = false;
let missing = 0;
try {
if (apply) {
[{ locked }] = await db.$queryRawUnsafe(
"SELECT pg_try_advisory_lock(hashtext('pac:index-preparation')) AS locked",
);
if (!locked) throw Error('已有索引准备任务,停止');
await db.$executeRawUnsafe("SET maintenance_work_mem='64MB'");
await db.$executeRawUnsafe('SET max_parallel_maintenance_workers=0');
await db.$executeRawUnsafe("SET lock_timeout='5s'");
await db.$executeRawUnsafe("SET statement_timeout='45min'");
}
const deadline = Date.now() + 6 * 60 * 60 * 1000;
for (const [name, table, columns] of indexes) {
const old = await inspect(name);
if (old) {
if (!validIndex(old, table, columns))
throw Error(`索引无效或定义不符:${name};人工检查后再处理`);
log('index_valid', { name, size: old.size });
continue;
}
log('index_missing', { name });
missing++;
if (!apply) continue;
let idle = 0;
while (idle < 6) {
if (Date.now() > deadline) throw Error('等待空闲超过六小时,未启动后续索引');
idle = (await busy()) ? 0 : idle + 1;
log('idle_sample', { name, consecutive: idle });
if (idle < 6) await delay(30000);
}
const [{ pid }] = await db.$queryRawUnsafe('SELECT pg_backend_pid() AS pid');
log('index_start', { name, pid });
let done = false,
failure;
const work = db
.$executeRawUnsafe(
`CREATE INDEX CONCURRENTLY "${name}" ON "public"."${table}" (${columns.map((c) => '"' + c + '"').join(',')})`,
)
.catch((e) => {
failure = e;
})
.finally(() => {
done = true;
});
let healthFailures = 0;
while (!done) {
await Promise.race([work, delay(30000)]);
if (done) break;
const rows = await observer.$queryRawUnsafe(
'SELECT phase,blocks_total,blocks_done FROM pg_stat_progress_create_index WHERE pid=$1',
pid,
);
log('index_progress', { name, progress: rows });
try {
const r = await fetch('http://127.0.0.1:3101/health', {
signal: AbortSignal.timeout(5000),
});
if (!r.ok || (await r.json()).status !== 'ok') throw Error('health');
healthFailures = 0;
} catch {
healthFailures++;
}
if (healthFailures >= 2) {
await observer.$queryRawUnsafe('SELECT pg_cancel_backend($1::int)', pid);
log('index_cancelled', { name, reason: 'PAC health failed twice' });
await work;
throw Error('PAC 健康检查失败,已取消本任务索引;不得自动重试');
}
}
await work;
if (failure)
throw Error(`索引创建失败 ${name} (${failure.code || 'database error'});检查有效性后处理`);
const current = await inspect(name);
if (!validIndex(current, table, columns)) throw Error(`索引验证失败:${name}`);
log('index_valid', { name, size: current.size });
}
if (!apply && missing > 0) throw Error(`缺少 ${missing} 个索引`);
log(apply ? 'complete' : 'check_complete');
} finally {
if (locked)
await db
.$queryRawUnsafe("SELECT pg_advisory_unlock(hashtext('pac:index-preparation'))")
.catch(() => {});
await db.$disconnect();
await observer.$disconnect();
}
}
if (require.main === module)
main().catch((e) => {
console.error(JSON.stringify({ event: 'failed', message: e.message }));
process.exitCode = 1;
});
-- 运维使用 RDS 管理账号首次执行;不要连接 pac 库安装 AI 扩展。
-- 密码使用 DMS 安全界面或 psql \password friday_ai 设置,不放进 SQL 文件。
CREATE ROLE friday_ai LOGIN;
CREATE DATABASE friday_ai OWNER friday_ai TEMPLATE template0
ENCODING 'UTF8' LC_COLLATE 'C' LC_CTYPE 'en_US.utf8';
ALTER DATABASE friday_ai SET timezone TO 'UTC';
-- 切换连接到 friday_ai 后再执行以下两句。
-- CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- CREATE EXTENSION IF NOT EXISTS btree_gist;
# 在现有生产网关安装。替换 AI_DOMAIN、RELAY_DOMAIN、证书路径后 nginx -t。
# 前提:只有此网关可访问应用机 3103/3104/3106;真实来源仅一层代理。
server {
listen 80;
server_name AI_DOMAIN RELAY_DOMAIN;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name AI_DOMAIN;
ssl_certificate /REQUIRED/AI/fullchain.pem;
ssl_certificate_key /REQUIRED/AI/privkey.pem;
client_max_body_size 25m;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_send_timeout 300s;
proxy_read_timeout 3600s;
location /api/ {
proxy_pass http://172.16.4.239:3104;
proxy_buffering off;
proxy_cache off;
}
location = /health { proxy_pass http://172.16.4.239:3104; access_log off; }
location = /health/ready { proxy_pass http://172.16.4.239:3104; access_log off; }
location / { proxy_pass http://172.16.4.239:3103; }
}
server {
listen 443 ssl;
server_name RELAY_DOMAIN;
ssl_certificate /REQUIRED/RELAY/fullchain.pem;
ssl_certificate_key /REQUIRED/RELAY/privkey.pem;
location = /hook/ecom {
limit_except POST { deny all; }
client_max_body_size 20m;
proxy_pass http://172.16.4.239:3106;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_connect_timeout 2s;
proxy_read_timeout 8s;
}
location / { return 404; }
}
# 安装到 /etc/cron.d/friday-ai-health;仅在 AI 和中转完成部署后启用。
# 运维采集 JSON 日志:连续 3 次 degraded 或超过 5 分钟无新日志须告警。
*/2 * * * * root flock -n /run/friday-ai-health.lock /bin/bash /data/pac/deploy/check-ai-health.sh >> /var/log/friday-ai-health.log 2>&1
/var/log/friday-ai-health.log {
daily
rotate 14
compress
missingok
notifempty
create 0600 root root
}
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { queueHealthy, probeJson } from '../ai-health.mjs';
import { createServer } from 'node:http';
test('过期租约或五分钟积压报告异常,缺失指标不能冒充正常', () => {
assert.equal(queueHealthy({ oldestSeconds: 0, expiredLeases: 0 }), true);
for (const x of [{ oldestSeconds: 301 }, { oldestSeconds: 0, expiredLeases: 1 }, {}])
assert.equal(queueHealthy(x), false);
});
test('健康检查识别 HTTP 200 信封错误和数据库不可用', async () => {
const server = createServer((req, res) => {
res.end(
req.url === '/ok'
? ' {"status":"ok","db":"up"} '
: req.url === '/db'
? ' {"status":"ok","db":"down"} '
: '{"code":10004,"msg":"not found"}',
);
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
try {
const url = `http://127.0.0.1:${server.address().port}`;
const expected = (b) => b.status === 'ok' && b.db === 'up';
await probeJson(url + '/ok', expected);
await assert.rejects(probeJson(url + '/db', expected));
await assert.rejects(probeJson(url + '/missing', expected));
} finally {
await new Promise((r) => server.close(r));
}
});
......@@ -7,9 +7,18 @@ import { spawnSync } from 'node:child_process';
const source = resolve('deploy/deploy-ai.sh');
// 用假 Docker 注入失败,验证真实脚本不会迁移/切换不完整版本,不触碰本地或远端容器。
function runDeploy({ fail = '', check = false, branch = 'test', mismatch = false, piped = false } = {}) {
function runDeploy({
fail = '',
check = false,
branch = 'test',
mismatch = false,
piped = false,
managed = false,
target = 'staging',
} = {}) {
const root = mkdtempSync(join(tmpdir(), 'ai-deploy-flow-'));
const bin = join(root, 'bin'), log = join(root, 'commands.log');
const bin = join(root, 'bin'),
log = join(root, 'commands.log');
for (const dir of [bin, join(root, '.git'), join(root, 'deploy')]) mkdirSync(dir);
copyFileSync(source, join(root, 'deploy/deploy-ai.sh'));
writeFileSync(log, '');
......@@ -21,7 +30,7 @@ function runDeploy({ fail = '', check = false, branch = 'test', mismatch = false
'rev-parse --git-common-dir') echo .git ;;
esac`,
flock: 'exit 0',
docker: String.raw`if [[ "$*" == *' run '* && "$*" != *'--interactive=false'* ]]; then cat >/dev/null; fi
docker: String.raw`if [[ ( "$*" == *' run '* || "$*" == *' exec '* ) && "$*" != *'--interactive=false'* ]]; then cat >/dev/null; fi
if [[ -n "$DEPLOY_TEST_FAIL" && "$*" == *"$DEPLOY_TEST_FAIL"* ]]; then exit 43; fi
case "$*" in
*' ps -q ai-service') echo test-service ;;
......@@ -34,16 +43,36 @@ function runDeploy({ fail = '', check = false, branch = 'test', mismatch = false
};
try {
for (const [name, body] of Object.entries(stubs))
writeFileSync(join(bin, name), '#!/usr/bin/env bash\nset -eu\n' + common + body + '\n', { mode: 0o755 });
writeFileSync(join(bin, name), '#!/usr/bin/env bash\nset -eu\n' + common + body + '\n', {
mode: 0o755,
});
const script = join(root, 'deploy/deploy-ai.sh');
const result = spawnSync('bash', piped ? ['-s'] : [script, 'staging', ...(check ? ['--check'] : [])], {
...(piped ? { input: `bash '${script}' staging --check\nprintf 'FOLLOWUP_REACHED\\n'\n` } : {}),
encoding: 'utf8', timeout: 10000,
env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, DEPLOY_TEST_LOG: log,
DEPLOY_TEST_FAIL: fail, DEPLOY_TEST_BRANCH: branch, DEPLOY_TEST_MISMATCH: mismatch ? '1' : '0' },
});
const result = spawnSync(
'bash',
piped ? ['-s'] : [script, target, ...(check ? ['--check'] : [])],
{
...(piped
? {
input: `bash '${script}' ${target} ${check ? '--check' : '--no-pull'}\nprintf 'FOLLOWUP_REACHED\\n'\n`,
}
: {}),
encoding: 'utf8',
timeout: 10000,
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
DEPLOY_TEST_LOG: log,
COMPOSE_MANAGED: managed ? '1' : '0',
DEPLOY_TEST_FAIL: fail,
DEPLOY_TEST_BRANCH: branch,
DEPLOY_TEST_MISMATCH: mismatch ? '1' : '0',
},
},
);
return { result, commands: readFileSync(log, 'utf8').trim().split('\n') };
} finally { rmSync(root, { recursive: true, force: true }); }
} finally {
rmSync(root, { recursive: true, force: true });
}
}
test('Docker 构建和预检成功后才停 AI、迁移、切换并校验镜像', () => {
......@@ -52,9 +81,15 @@ test('Docker 构建和预检成功后才停 AI、迁移、切换并校验镜像'
const at = (s) => commands.findIndex((c) => c.includes(s));
assert.ok(at('build ai-web') < at('run --rm --no-deps -T --interactive=false ai-preflight'));
assert.ok(at('ai-preflight') < at('stop ai-service ai-web'));
assert.ok(at('stop ai-service ai-web') < at('run --rm --no-deps -T --interactive=false ai-migrate'));
assert.ok(
at('stop ai-service ai-web') < at('run --rm --no-deps -T --interactive=false ai-migrate'),
);
assert.ok(at('prisma migrate status') < at('up -d --no-deps --force-recreate --wait'));
assert.ok(commands.filter((c) => c.startsWith('docker compose')).every((c) => c.includes('-p pac-ai -f docker-compose.ai.yml')));
assert.ok(
commands
.filter((c) => c.startsWith('docker compose'))
.every((c) => c.includes('-p pac-ai -f docker-compose.ai.yml')),
);
assert.ok(!commands.some((c) => /systemctl|pnpm install|stop pac-|up.*pac-service/.test(c)));
});
for (const failure of ['build ai-web', 'run --rm --no-deps -T --interactive=false ai-preflight']) {
......@@ -65,7 +100,9 @@ for (const failure of ['build ai-web', 'run --rm --no-deps -T --interactive=fals
});
}
test('迁移失败不启动新旧应用', () => {
const { result, commands } = runDeploy({ fail: 'run --rm --no-deps -T --interactive=false ai-migrate' });
const { result, commands } = runDeploy({
fail: 'run --rm --no-deps -T --interactive=false ai-migrate',
});
assert.equal(result.status, 43);
assert.ok(!commands.some((c) => c.includes('up -d')));
});
......@@ -85,7 +122,22 @@ test('运行容器镜像与构建镜像不一致时拒绝报告成功', () => {
});
test('SSH 管道执行预检后仍执行后续命令,不被 Compose 吞掉标准输入', () => {
const { result } = runDeploy({ piped: true, check: true });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /FOLLOWUP_REACHED/);
});
test('完整发布也不吞后续 SSH 命令', () => {
const { result } = runDeploy({ piped: true });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /FOLLOWUP_REACHED/);
});
test('生产托管模式每个 Compose 操作均加载生产端口覆盖', () => {
const { result, commands } = runDeploy({ target: 'production', branch: 'main', managed: true });
assert.equal(result.status, 0, result.stderr);
assert.ok(
commands
.filter((c) => c.startsWith('docker compose'))
.every((c) => c.includes('-f docker-compose.ai.managed.yml')),
);
});
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
function run({ check = false, fail = '' } = {}) {
const root = mkdtempSync(join(tmpdir(), 'ingress-deploy-'));
try {
for (const d of ['deploy', 'bin', '.git']) mkdirSync(join(root, d));
copyFileSync(resolve('deploy/deploy-ingress.sh'), join(root, 'deploy/deploy-ingress.sh'));
const log = join(root, 'log');
writeFileSync(log, '');
const scripts = {
git: `case "$*" in 'branch --show-current') echo main;; 'rev-parse --git-common-dir') echo .git;; 'rev-parse --short HEAD') echo abc123;; esac`,
flock: 'exit 0',
docker: `printf '%s\\n' "$*" >> "$LOG"\n[[ -z "$FAIL" || "$*" != *"$FAIL"* ]] || exit 43\ncase "$*" in *' ps -q channel-ingress') echo container;; 'image inspect '*|'inspect '*) echo sha256:expected;; esac`,
};
for (const [n, s] of Object.entries(scripts))
writeFileSync(join(root, 'bin', n), '#!/bin/bash\nset -eu\n' + s, { mode: 0o755 });
const r = spawnSync(
'bash',
[join(root, 'deploy/deploy-ingress.sh'), 'production', ...(check ? ['--check'] : [])],
{
encoding: 'utf8',
timeout: 10000,
env: {
...process.env,
PATH: join(root, 'bin') + ':' + process.env.PATH,
COMPOSE_MANAGED: '1',
LOG: log,
FAIL: fail,
},
},
);
return { r, log: readFileSync(log, 'utf8') };
} finally {
rmSync(root, { recursive: true, force: true });
}
}
test('生产中转预检加载生产端口,检查本地分流,不迁移不重启', () => {
const { r, log } = run({ check: true });
assert.equal(r.status, 0, r.stderr);
assert.ok(
log
.split('\n')
.filter((x) => x.startsWith('compose '))
.every((x) => x.includes('docker-compose.ingress.managed.yml')),
);
assert.match(log, /c.localAccounts.length\|\|c.localToken/);
assert.doesNotMatch(log, /ingress-migrate|up -d|stop /);
});
test('中转预检失败不能执行迁移或替换运行容器', () => {
const { r, log } = run({ fail: 'readConfig' });
assert.equal(r.status, 43);
assert.doesNotMatch(log, /ingress-migrate|up -d/);
});
test('中转路由校验失败保持原容器,成功后才切换镜像', () => {
const bad = run({ fail: 'verifyRouting' });
assert.equal(bad.r.status, 43);
assert.doesNotMatch(bad.log, /up -d/);
const good = run();
assert.equal(good.r.status, 0, good.r.stderr);
assert.ok(good.log.indexOf('verifyRouting') < good.log.indexOf('up -d'));
assert.doesNotMatch(good.log, /stop ai/);
});
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
function probe(body, ai) {
const root = mkdtempSync(join(tmpdir(), 'health-watch-'));
try {
const bin = join(root, 'bin');
mkdirSync(bin);
writeFileSync(
join(bin, 'curl'),
'#!/bin/bash\nprintf "%s\\n200" "$BODY"\nprintf "%s\\n" "$*" > "$CALL"\n',
{ mode: 0o755 },
);
const r = spawnSync('bash', [resolve('deploy/health-watch.sh'), '--dry-run'], {
encoding: 'utf8',
env: {
...process.env,
PATH: bin + ':' + process.env.PATH,
BODY: body,
CALL: join(root, 'call'),
WATCH_URL: 'https://test.invalid',
WATCH_STATE_DIR: root,
WATCH_NAME: 'check',
WATCH_HEARTBEAT_HOUR: '-1',
WATCH_CONTROL_URL: '',
ALERT_WEBHOOK_URL: '',
...(ai
? { WATCH_PATH: '/health/ready', WATCH_REQUIRE_DB: '1' }
: { WATCH_PATH: '/health', WATCH_REQUIRE_DB: '0' }),
},
});
assert.equal(r.status, 0, r.stderr);
return {
state: readFileSync(join(root, 'check.state'), 'utf8'),
call: readFileSync(join(root, 'call'), 'utf8'),
};
} finally {
rmSync(root, { recursive: true, force: true });
}
}
test('原 PAC 探针保持 /health 判据,拒绝 HTTP 200 错误信封', () => {
assert.match(probe('{"status":"ok"}', false).state, /fails=0/);
assert.match(probe('{"code":10004}', false).state, /fails=1/);
});
test('AI 外部探针要求数据库就绪且访问 ready 路由', () => {
const good = probe('{"status":"ok","db":"up"}', true);
assert.match(good.call, /health\/ready/);
assert.match(good.state, /fails=0/);
assert.match(probe('{"status":"ok","db":"down"}', true).state, /fails=1/);
});
import { createRequire } from 'node:module';
import { test } from 'node:test';
import assert from 'node:assert/strict';
const { validIndex } = createRequire(import.meta.url)('../prepare-pac-indexes.cjs');
const good = {
indisvalid: true,
indisready: true,
indisunique: false,
table_name: 'facts',
method: 'btree',
predicate: null,
expressions: null,
columns: ['host', 'tenant'],
};
test('同名索引必须有效、列序一致、无额外谓词,不能将失败残留当作完成', () => {
assert.equal(validIndex(good, 'facts', ['host', 'tenant']), true);
for (const patch of [
{ indisvalid: false },
{ indisready: false },
{ indisunique: true },
{ columns: ['tenant', 'host'] },
{ predicate: 'false' },
{ expressions: 'lower(host)' },
{ table_name: 'other' },
{ method: 'hash' },
])
assert.equal(validIndex({ ...good, ...patch }, 'facts', ['host', 'tenant']), false);
});
# 与 PAC 托管环境一致:远端网关经应用机私网访问,安全组仅放行网关。
services:
ai-service:
ports: !override ['3104:3104']
ai-web:
ports: !override ['3103:3103']
# 与 PAC 托管环境一致;3106 不得对公网开放,网关必须覆盖 X-Real-IP。
services:
channel-ingress:
ports: !override ['3106:3106']
# FridayAI 生产部署准备与实施
> 2026-10-10 生产准备;已准备索引任务和独立密钥草稿,尚未发布生产应用、建 AI 库或修改运行配置。范围:现有 PAC + FridayAI + 独立企微中转;只接已有 5i5ya 数据,不开通 Friday SaaS,不启用宿主自动回写。
## 1. 当前生产与测试的差异
| 项目 | 生产核查结果 / 本次处理 |
| --- | --- |
| 应用机 | `ssh pac-friday`,`47.99.62.30:3335`,源码 `/data/pac`,`main` |
| 代码 | 服务器 `d9a9fda`;准备基线 `main@3b81549`,其文件内容与已验证 `test@3cd4acf` 相同。正式发布重新确认 SHA |
| 已运行 | `pac-pac-service-1`、`pac-pac-web-1`、`pac-pac-docs-1`;尚无 AI/中转容器 |
| 网络 | 已有 `pac_default`;生产由独立网关访问应用机私网,测试由应用机本地 Nginx 转发 |
| 数据库 | 现有 RDS PostgreSQL **18.3**;端点 `fridaypac.pg.rds.aliyuncs.com:5432`。现有 `pac` 库,无 `friday_ai` 库 |
| 字符集、时区 | PAC:UTF8、`LC_COLLATE=C`、`LC_CTYPE=en_US.utf8`;本次查询会话时区 `Asia/Shanghai`。AI 新库使用相同字符集/排序规则,单独设 UTC;不改 PAC 库和 RDS 全局时区 |
| 数据 | 唯一宿主 `jvs-dw`(5i5ya),启用;抽样患者及 manifest 均为 `tenantId=ruier-grp`。不重复首灌,不创建 `friday-market` |
| PAC 前置 | 未设置 `PAC_READ_SECRET`;缺 7 条迁移(含本次移除派生表);`clinic_names` 当前为空 |
| 资源快照 | 约 15.4 GiB RAM、11.7 GiB 可用;`/data` 可用 151 GiB。PAC 后端当时约 2.82 GiB / 8 GiB 上限;不代表摄入/重算高峰 |
| RDS 规模 | 统计估算约 122 万患者、4,732 万事实、4,722 万事务;连接当时 42 / 820。未执行全表计数 |
| 基础工具 | Compose 2.32.4,满足 raw env / override 要求;主机未发现 `psql`、`nginx`、`jq`。Node/pnpm 随镜像提供,不需要安装到主机 |
生产仍使用同一份 `/data/pac` 源码,三个 Compose 项目独立发布。生产命令统一加 `COMPOSE_MANAGED=1`,加载仓库中的私网端口覆盖;测试环境不加。无需主机 Node/pnpm。
准备执行记录见 [上线准备验收](qa/2026-10-10-production-readiness.md)。本手册中的域名、账号和供应商值是待运维填写项,不能直接用测试值替代。
## 2. 发布前交付清单
| 提供方 | 必需资料 / 动作 |
| --- | --- |
| 运维:域名 | 确认 AI 生产域名与回调域名,例如 `fai.friday.tech`、`relay.friday.tech`(仅建议,尚未确认);DNS、HTTPS 证书、网关配置权限 |
| 运维:网络 | 网关到应用机私网 IP 的 3103 / 3104 / 3106;仅允许网关来源,禁止公网直连。核对真实代理层数;确保应用机容器可访问 RDS、ECom、OSS、模型服务 |
| 运维:RDS | 在现有实例建独立 `friday_ai` 库和专用账号;当前 `pac` 用户没有 CREATEDB / CREATEROLE 权限。由有权限人员操作,不复用 PAC 数据库账号 |
| 运维:备份 | PAC 发布前可恢复的 RDS 备份;AI 上线后同样纳入自动备份和恢复演练。确认大表建索引所需 RDS 磁盘、IOPS 余量与低峰窗口(本次没有云控制台权限核查) |
| 业务 / ECom | 生产 API 地址、accessId、accessSecret、回调出口 IP、生产回调 URL 登记;确认凭据/回调能与测试隔离,不能把现有测试回调直接改成生产 |
| 业务 / ECom | 首个验收企微号、收费/设备额度及归属企业;不能与本地/测试同时托管同一号,不能在重试时重复申请设备 |
| 运维:OSS | 生产 region、私有 bucket、RAM 凭据;允许必要读写及签名下载,前缀 `friday-ai-prod`。不复制测试附件和密钥 |
| 模型 | 生产模型 API Key、额度;代码默认阿里云兼容接口 / `qwen3.8-max`。需确认该账号有模型访问权限;换端点/型号时显式配置 |
| 实施:客户 | 集团名称、首位客户管理员姓名/手机号、首批诊所及对应 PAC 节点、企微绑定员工、席位数量和期限 |
| 实施:平台 | 平台管理员姓名/手机号;生产使用正式资料,不默认复制测试的“小克”或本地账号 |
部署人员生成并保管:Ed25519 私钥、PAC→AI 服务读取密钥、中转→AI 密钥、数据库密码。各环境独立;不把密钥写进 Git、工单正文或公开日志。
**不需要索取 5i5ya 的 PAC appSecret/pushSecret。** AI 读取使用独立 `PAC_READ_SECRET`;本期先用 FridayAI 原生登录。5i5ya 以后要内嵌 AI 时,再单独登记 FridayAI 应用和对接换票,不复用 PAC 的应用凭据。
## 3. 更新代码与备份(应用机)
以下为正式实施时执行的命令;本次准备阶段不执行。所有占位值须先替换。各阶段串行,不与摄入重算、其他部署并发。
```bash
ssh pac-friday
bash
set -euo pipefail
umask 077
cd /data/pac
[ "$(git branch --show-current)" = main ]
git status --short
git diff --exit-code
git diff --cached --exit-code
BACKUP=/data/backups/friday-ai-release/$(date -u +%Y%m%dT%H%M%SZ)
install -d -m 700 "$BACKUP"
cp -a apps/pac-service/.env "$BACKUP/pac-service.env"
cp -a apps/pac-web/.env "$BACKUP/pac-web.env"
git rev-parse HEAD > "$BACKUP/previous-source-sha"
# 回退以运行镜像为准,源码 HEAD 不保证就是旧镜像的构建版本。
docker inspect --format '{{.Name}} {{.Config.Image}} {{.Image}} {{.State.StartedAt}}' \
pac-pac-service-1 pac-pac-web-1 pac-pac-docs-1 > "$BACKUP/previous-containers.txt"
```
先由运维确认 RDS 备份成功并登记备份 ID、恢复方法。保留旧运行镜像,发布期间不清理 Docker 镜像/缓存。
生产现有未跟踪文件包括 `.env.bak-*`、`pids_sample.txt`。人工检查后移到上述受保护备份目录;不要删除、不提交。AI 原脚本会拒绝未跟踪的非环境文件。
```bash
git fetch origin main
git merge --ff-only origin/main
git rev-parse HEAD # 记录正式发布 SHA,不接受未经验证的新提交混入。
docker network inspect pac_default >/dev/null
docker compose version
```
源码保持同一份 `/data/pac`。只更新源码不会替换运行容器;PAC / AI / 中转分别发布。分支继续 feature → test 验证 → feature → main,不合 test 到 main。
## 4. RDS 首次建库(运维)
使用 RDS 控制台 / DMS / 有权限的 PostgreSQL 客户端完成:
```sql
-- 仅首次创建;密码通过控制台或 psql 的 \password friday_ai 设置。
CREATE ROLE friday_ai LOGIN;
CREATE DATABASE friday_ai OWNER friday_ai TEMPLATE template0
ENCODING 'UTF8' LC_COLLATE 'C' LC_CTYPE 'en_US.utf8';
ALTER DATABASE friday_ai SET timezone TO 'UTC';
```
连接到 **friday_ai** 后,由有权限的账号执行:
```sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS btree_gist;
```
使用 **friday_ai 账号的新连接**验证 `current_database()`、`current_user`、`SHOW server_encoding`、`SHOW TimeZone`,分别应为 `friday_ai` / `friday_ai` / `UTF8` / `UTC`;账号必须有本库建 schema、表、函数、触发器及索引的权限,供 Prisma 迁移使用。扩展在现有 RDS 可用,但本次没有尝试安装或验证新账号权限。
不修改 RDS 全局参数,不在 `pac` 库建 AI 表。中转复用 `friday_ai` 库,使用独立 `ingress` schema;AI 使用 `app` schema。AI / 中转无需新增 Redis。
数据库 URL 密码需 URL 编码;TLS 要求按 RDS 实际策略设置并分别验证 Prisma 与 node-postgres 客户端,不能以关闭证书校验处理连接失败。
## 5. 准备配置(应用机)
已在生产应用机生成独立材料:`/data/pac-ops/readiness-20261010/config/`,目录 700,文件 600。包含 JWT 私钥、`pac-read.key`、`ingress.key`、`database.key` 和两份 `.env.draft`。不包含测试凭据,尚未投入运行。
1. 运维通过安全渠道取得 **应用数据库密码**(`database.key`),为 `friday_ai` 设置密码;无需交出 RDS 管理账号。也可由运维另定应用密码并回填两份草稿中的 URL。
2. 补全两份草稿的全部 `REQUIRED_...` 值。检查 RDS TLS 策略、ECom/OSS/模型的生产凭据。
3. 首次发布时安装配置;已有配置时先备份、比较,不覆盖密钥。
```bash
cd /data/pac
PREP=/data/pac-ops/readiness-20261010/config
if grep -l 'REQUIRED_' "$PREP"/*.env.draft; then echo "先补全生产配置"; exit 1; fi
install -d -m 700 deploy/secrets
[ ! -e deploy/secrets/friday-ai-jwt.pem ]
[ ! -e apps/ai-service/.env ]
[ ! -e apps/channel-ingress/.env ]
install -m 600 "$PREP/friday-ai-jwt.pem" deploy/secrets/friday-ai-jwt.pem
install -m 600 "$PREP/ai-service.env.draft" apps/ai-service/.env
install -m 600 "$PREP/channel-ingress.env.draft" apps/channel-ingress/.env
```
将上述配置目录中 `pac-read.key` 内容追加为 PAC `apps/pac-service/.env` 的 `PAC_READ_SECRET`(如已存在就保留并复用,不能重复定义)。同一值填到 AI。保留其他 PAC 配置,不重新运行 gen-env 覆盖整份文件。
`apps/ai-service/.env`(值不加引号,不 source):
```dotenv
AI_DATABASE_URL=postgresql://friday_ai:<URL编码密码>@fridaypac.pg.rds.aliyuncs.com:5432/friday_ai
AI_PAC_BASE_URL=http://pac-service:3101
AI_PAC_READ_SECRET=<与生产PAC_READ_SECRET一致>
AI_ECOM_BASE_URL=<生产接口根地址>
AI_ECOM_ACCESS_ID=<生产凭据>
AI_ECOM_ACCESS_SECRET=<生产凭据>
AI_INGRESS_TOKEN=<ingress.key内容>
AI_TRUST_PROXY_HOPS=1
AI_LLM_API_KEY=<生产模型密钥>
AI_OUTBOX_WORKER=off
AI_SCHEDULER=off
AI_OSS_REGION=<生产region>
AI_OSS_BUCKET=<生产bucket>
AI_OSS_ACCESS_KEY_ID=<生产OSS凭据>
AI_OSS_ACCESS_KEY_SECRET=<生产OSS凭据>
AI_OSS_PREFIX=friday-ai-prod
```
`AI_TRUST_PROXY_HOPS=1` 仅适用于公网 → 一层网关 → AI;若有其他代理,先核对再改。Web 不需 `.env`;无需 AI_PUBLIC_BASE_URL / 时区变量。JWT 路径及 UTC 已由 Compose / 镜像指定。
`apps/channel-ingress/.env`:
```dotenv
INGRESS_DATABASE_URL=postgresql://friday_ai:<URL编码密码>@fridaypac.pg.rds.aliyuncs.com:5432/friday_ai
INGRESS_AI_URL=http://ai-service:3104
INGRESS_TOKEN=<与AI_INGRESS_TOKEN一致>
INGRESS_ECOM_IPS=<供应商确认的生产出口IP,逗号分隔>
```
生产不配置 `INGRESS_LOCAL_ACCOUNTS` / `INGRESS_LOCAL_TOKEN` / `AI_INGRESS_URL` / `AI_INGRESS_PULL_TOKEN` / 旧 `AI_RELAY_*`。不配置宿主权限账号,未配置时业务继续走人工宿主录入。
备份两份新 `.env`、JWT 私钥和 `/data/pac-ops`,权限 600 / 700。以后升级沿用,不重新生成密钥。
## 6. 先升级 PAC,再完成数据准备
**执行本节前先完成第 7 节索引检查。**
前 6 条缺失迁移已按生产 `_prisma_migrations` 对比代码确认;第 7 条为本次删除派生表迁移,已在测试服务器验证并由功能分支合入 main,生产尚未执行:
- `20260827140000_host_read_secrets`
- `20260827160000_pt_patient_clinic_idx`
- `20260915100000_fact_window_occurred_idx`
- `20260915100100_fact_window_planned_idx`
- `20260915100200_patient_step_latest`
- `20261008170000_fact_host_tenant_statistics`
- `20261009160000_remove_unused_patient_step_latest`
其中 3 条为热表 `CREATE INDEX CONCURRENTLY`,第 6 条含 ANALYZE。已单独启动索引准备任务,发布前按下述命令确认全部有效;提前创建索引不登记 Prisma 迁移,发布时同名 `IF NOT EXISTS` 会跳过创建并正常登记。需要 RDS 额外磁盘和 IO;索引耗时与应用停服时长分开记录。迁移先执行,成功后才替换 PAC 容器。选择低峰并确认无大批量摄入/重算;不要为了 AI 上线手动全量重算画像或召回。
```bash
cd /data/pac
COMPOSE_MANAGED=1 bash deploy/deploy-prod.sh --no-pull
curl -fsS --max-time 5 http://127.0.0.1:3101/health
# 必须检查 JSON status=ok;/pac/v1/health 不是健康接口。
```
迁移失败立刻停止发布。若并发索引中断,先检查 `pg_index.indisvalid` 和失败迁移;不得仅因索引同名存在就标记 applied,不使用 `migrate reset`,不盲目循环重试。
```bash
# 生产 clinic_names 当前为空;从现有 DW 派生字典,不重新摄入患者。
docker exec pac-pac-service-1 node dist/cli/refresh-clinic-names.cli.js \
--dir=./data/jvs-dw --host=jvs-dw
```
检查字典得到实际诊所名。未使用的治疗步骤汇总表、查询接口及回填 CLI 已撤掉并合入 main,不需要回填或重新首灌 5i5ya。旧建表迁移保留,新迁移删除派生表;生产当前尚未建此表,按顺序迁移会创建后删除空表,不会扫描患者事实。若部署前状态发生变化、该表已有数据,先备份该表并在构建完成后停止旧 PAC 后端,再迁移和启动新版。
## 7. 发布前索引检查
生产后台记录在 `/data/pac-ops/readiness-20261010/`。只看 `index-exit-code=0` 还不够,检查日志及数据库当前有效性:
```bash
cat /data/pac-ops/readiness-20261010/index-exit-code
tail -10 /data/pac-ops/readiness-20261010/index.log
docker cp deploy/prepare-pac-indexes.cjs pac-pac-service-1:/tmp/prepare-pac-indexes.cjs
docker exec pac-pac-service-1 node /tmp/prepare-pac-indexes.cjs --check
```
应显示三个 `index_valid`、没有 `index_missing`。`--check` 只读,不构建。任务仍运行时不重复启动、不重启 PAC。发生失败先检查索引有效性和业务负载;不能自动重试或把无效索引标作已迁移。
## 8. 发布 AI 和中转
先确认安全组仅允许网关访问应用机 3103/3104/3106。首次部署先关闭两个业务开关;日常升级保留已验收的开关。
```bash
cd /data/pac
# 构建和配置预检,不迁移或重启;尚不证明供应商、OSS、RDS 可访问。
COMPOSE_MANAGED=1 bash deploy/deploy-ai.sh production --check
COMPOSE_MANAGED=1 bash deploy/deploy-ingress.sh production --check
# 第 6 节 PAC 升级和读密钥就绪后执行。
COMPOSE_MANAGED=1 bash deploy/deploy-ai.sh production --no-pull
COMPOSE_MANAGED=1 bash deploy/deploy-ingress.sh production
bash deploy/check-ai-health.sh
```
脚本串行构建、预检、迁移、检查容器健康和镜像 ID,使用同一发布锁。AI 迁移前停止旧 AI;失败保持停止,避免旧版本写不兼容 schema。中转独立发布,普通 AI 发布不重启中转。生产中转预检拒绝本地调试路由或令牌。
后续 CLI、看日志和改配置使用以下函数,避免漏掉生产端口覆盖:
```bash
ai() { docker compose -p pac-ai -f docker-compose.ai.yml \
-f docker-compose.ai.managed.yml "$@"; }
ingress() { docker compose -p pac-ingress -f docker-compose.ingress.yml \
-f docker-compose.ingress.managed.yml "$@"; }
```
## 9. HTTPS 与网关(运维)
在 **生产网关** 配置,不能在应用机照抄测试的 certbot / sites-enabled 命令。网关主机、登录权限、实际代理链、DNS 和证书须运维确认;本次未登录网关验证。
| 生产域名 | 路由 | 上游 |
| --- | --- | --- |
| 确认后的 AI 域名 | `/api/`、`/health`、`/health/ready` | `http://172.16.4.239:3104` |
| 同上 | `/` | `http://172.16.4.239:3103` |
| 确认后的回调域名 | 仅 `POST /hook/ecom` | `http://172.16.4.239:3106` |
完整模板:`deploy/production/friday-ai-gateway.conf.example`;替换域名、证书路径并核对私网 IP。AI 行为与 `deploy/nginx/friday-ai-test.conf` 对齐,保留 API 原路径、SSE 关闭缓冲/缓存、HTTP/1.1、长连接读取超时、25m 上传上限;改域名/证书/上游 IP。不加阻止宿主 iframe 的 X-Frame-Options/CSP。AI 与 PAC 使用独立域名,保留 PAC 现有 server 不动。
回调 HTTPS server 中使用以下 location,其余路径默认 404。生产不开放 `/local/*`、`/status` 或调试观察台:
```nginx
location = /hook/ecom {
limit_except POST { deny all; }
client_max_body_size 20m;
proxy_pass http://172.16.4.239:3106;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_connect_timeout 2s;
proxy_read_timeout 8s;
}
location / { return 404; }
```
网关覆盖客户端自填来源头;如网关前还有 LB/CDN,须先准确恢复可信来源 IP,再按真实代理层数配置 AI。不能把网关 IP 加入 ECom 供应商白名单来“解决”鉴权失败。
运维 `nginx -t` 成功后 reload;验证 HTTPS `/login`、`/health/ready`(JSON `status=ok`、`db=up`)及 PAC `/health`。ECom 回调必须等新中转就绪后再登记,保留测试 `relay.jarvismedical.asia` 的用途与配置。
## 10. 开户、组织、席位
以下沿用第 8 节的 `ai` 函数;新终端先重新定义函数。CLI 输出临时密码仅一次,私密交付,不放到部署日志。
```bash
# 客户开户:已核对 5i5ya 在 PAC 中的宿主/集团坐标。
ai exec ai-service node dist/cli/onboard-tenant.cli.js \
--partner=jvs-dw --partner-tenant=ruier-grp --name='<集团正式名称>' \
--admin='<首位客户管理员>' --phone='<正式手机号>' --dry-run
# 预演通过后,使用相同参数去掉 --dry-run 正式开户。
# 平台管理账号,与客户管理员可以不同。
ai exec ai-service node dist/cli/platform.cli.js grant \
--phone='<平台人员正式手机号>' --name='<姓名>' \
--role=platform_admin --reason='生产平台管理员开户'
```
先原生 `/login` 登录改密;不要求宿主内嵌同步上线。不使用 `--skip-pac-check` 绕过首发读取问题。
客户管理员在“组织架构”建立集团下的组织/门店,给门店关联 PAC 患者档案节点;按实际业务分配员工范围,再配置门店资料、服务时间及 AI 规则。`--partner-tenant` 是 `ruier-grp`,不是品牌名、host UUID 或宿主 appId;这个集团无需 Friday 市场用的 `--data-root`。
开户不自动导入所有诊所/员工。纯平台管理员也不会自动获得客户患者权限。用至少两个诊所验证范围隔离;验证患者姓名/病历号、就诊、预约、治疗事实和来源时间,不以健康接口代替业务验收。
平台页面可管理席位;CLI 同规则:
```bash
ai exec ai-service node dist/cli/hosting-seat.cli.js grant \
--tenant='<FridayAI租户UUID>' --user='<绑定员工UUID>' \
--reason='生产开通一个年度托管席位'
```
每次 `grant` 增加 **一个**席位,默认一年;只开批准的数量。`--user` 授予首次扫码权限,默认 24 小时;不带 user 需后续 authorize。业务期限制定后可用 `--expires=<带时区时间>`,永久需明确 `--permanent`。完整命令见 [托管席位](friday-ai-hosting-seats.md)。
## 11. 受控启用与验收
1. 确认生产 ECom 回调已到新中转、生产配置无本地调试分流;准备专用验收号,不先绑定有真实待发任务的业务号。
2. 在 AI `.env` 把 `AI_OUTBOX_WORKER=on`,保留 `AI_SCHEDULER=off`。该 worker 同时处理通讯录、入站、对外发送,**不是仅收不发开关**。
3. 使用当前已验证镜像重建 API 加载配置,然后扫码绑定验收号,验证通讯录、入站、AI/人工回复、OSS 图片和文件。
4. 绑定已有客户的号之前,由业务管理员核对 AI 自动接待策略、员工范围、门店服务资料;不要把测试号历史或本地会话复制到生产。
5. 验证一条指定预约的确认流程、时区和执行范围;业务确认开启定时预约任务后,再设 `AI_SCHEDULER=on`,同法重建 API。
```bash
export AI_IMAGE_TAG=$(cat "$(git rev-parse --git-common-dir)/ai-deploy/current-image-tag")
ai up -d --no-deps --force-recreate --wait --wait-timeout 120 ai-service
# restart 不会加载新环境变量。
```
在对外营业前,用验收号测试一次 AI 停服补发:`ai stop ai-service` → 发两条可辨识消息 → 确认中转已持久接收 → `ai start ai-service` → 确认各出现一次、积压归零。测试只停 AI,不停中转,不拿真实客户会话做停服演练。
查看中转队列(不输出密钥):
```bash
ingress exec -T --interactive=false channel-ingress node -e '
fetch("http://127.0.0.1:3106/status",{headers:{Authorization:"Bearer "+process.env.INGRESS_TOKEN}})
.then(async r=>{if(!r.ok)throw Error("status failed");console.log(await r.json())})
.catch(()=>process.exit(1))'
ai logs --tail 100 ai-service ai-web
ingress logs --tail 100 channel-ingress
```
生产无本地分流,正常时 pending 应回落至 0。将 `ingress_backlog`、持续重试、AI readiness、PAC health、RDS 磁盘/连接/IO、主机内存和证书到期接入运维告警。中转只保障 AI 停服期间缓存;中转自身重启、主机/数据库不可用不属于零丢失保证。
## 12. 启用服务器巡检与告警
AI 和中转就绪后在应用机执行:
```bash
cd /data/pac
bash deploy/check-ai-health.sh
install -m 644 deploy/production/friday-ai.cron /etc/cron.d/friday-ai-health
install -m 644 deploy/production/friday-ai.logrotate /etc/logrotate.d/friday-ai-health
```
每两分钟检查 AI 数据库就绪、Web、PAC、中转积压、已到期 outbox 与过期租约,输出一行不含患者/凭据的 JSON。未来预约不算积压;超过五分钟或有过期租约报告 `degraded`。巡检不自动重启、不重发消息。
**运维须配置通知,否则日志不等于告警:** 云日志采集 `/var/log/friday-ai-health.log`,连续三次 `degraded` 或五分钟未收到记录告警;配置接收人。另用云拨测或独立探针主机每分钟请求生产 AI `/health/ready`,同时检查 `status=ok`、`db=up`,连续三次失败告警。监控 RDS 存储/IOPS/连接、应用机内存和证书到期。
可复用 `deploy/health-watch.sh` 在另一台服务器运行:`WATCH_URL=https://<AI域名>`、`WATCH_PATH=/health/ready`、`WATCH_REQUIRE_DB=1`、`WATCH_NAME=friday-ai-prod`,私密配置通知 webhook,沿用该脚本的失败/恢复和每日心跳机制。不要覆盖现有 PAC 探针配置;生产域名未确定前不启用该探针。
服务器巡检覆盖进程内故障和积压,外部拨测覆盖主机/代理不可达,两者均不依赖开发电脑。用受控验收演练验证通知实际到达,再开放业务。
## 13. 日常升级与回退
- 日常只升级 AI:备份 → main 更新 → 第 8 节 AI 段;保留环境变量、密钥、RDS 和中转。PAC 读取契约未变时不必重发 PAC。
- 改 AI 开关:按第 11 节重建 API,使用记录的镜像标签与生产端口覆盖文件。
- 更新 PAC:始终 `COMPOSE_MANAGED=1 bash deploy/deploy-prod.sh --no-pull`,不套测试自带数据库配置。
- 中转独立更新:第 8 节中转段;仍有自身重启窗口,预先核对供应商重试策略。
- 回退先保存部署 SHA、运行镜像 ID、配置备份、迁移记录及队列状态,由开发确认旧镜像兼容已迁移 schema,再切旧镜像;不得直接 git reset / Prisma reset 或整库恢复。
- AI 与中转共用 `friday_ai` 库。AI 停止时中转仍在写,整库恢复会覆盖备份后已向 ECom 确认的消息;必须先保全队列及 AI 回执,再制定恢复步骤。
- 本次生产初始化不复用测试数据库、用户、企微设备或密钥;不改既有 PAC 宿主授权,不部署自动回写服务。
交付记录:发布 SHA、各镜像标签/ID、RDS 备份 ID、迁移结果、域名/证书、开户/席位编号、功能开关、真实读取/隔离/消息补发验收、已知限制。未使用的步骤汇总能力已撤掉,不执行回填。凭据单独交付。
# FridayAI 首次生产上线:运维交接单
应用机 `ssh pac-friday`,源码 `/data/pac`,分支 `main`。PAC 已运行;FridayAI 和中转尚未上线。详细命令见 [生产部署步骤](friday-ai-production-deployment.md),执行证据见 [准备记录](qa/2026-10-10-production-readiness.md)。
## 已准备
- 生产发布脚本、远端网关端口覆盖、独立中转部署和配置预检。
- 服务器健康/队列巡检、cron 与日志轮转模板;不依赖开发电脑。
- 生产专用 JWT 私钥、读取密钥、中转密钥、数据库应用密码及 env 草稿,位于应用机 `/data/pac-ops/readiness-20261010/config/`,尚未激活。
- PAC 索引后台准备任务;最终结果必须以准备记录、退出码和数据库有效性检查为准。
- 未使用的治疗步骤汇总功能已移除,不需要回填或全量重算患者。
## 请运维处理
| 项目 | 运维执行 / 提供 | 验收 |
| --- | --- | --- |
| RDS | 备份 PAC 并登记恢复点;确认磁盘/IOPS;执行 `deploy/production/friday-ai-database.sql` 建独立库和账号,在新库安装两个扩展 | 新应用账号可迁移 `friday_ai`;UTF8、UTC;不改 PAC 时区 |
| 数据库密码 | 安全交付专用应用账号密码,或使用已生成的 `database.key` 设置 | 两份 env 中 URL 一致;不需要交出 RDS 管理账号 |
| 域名/证书 | 确认 AI 和回调的生产域名,配置 DNS、证书 | HTTPS 正常;不覆盖 PAC 域名 |
| 网关/防火墙 | 按 `deploy/production/friday-ai-gateway.conf.example` 配置;仅网关访问应用机 3103/3104/3106 | API、SSE、上传可用;回调域名只开放 POST `/hook/ecom` |
| OSS | 生产私有 bucket、region、应用 RAM 凭据 | 生产附件上传及签名下载成功 |
| ECom | 生产 API 凭据、回调出口 IP;登记独立生产回调 | 测试回调保持原样;生产无本地分流 |
| 模型 | 生产 API Key 和模型访问额度 | 测试调用成功 |
| 告警 | 采集巡检日志;连续三次异常或五分钟缺日志告警;外部拨测;登记接收人 | 受控演练可收到告警和恢复通知 |
业务还需提供:集团正式名称、首位管理员姓名/手机号、平台管理员资料、首批诊所/员工范围、批准的席位数量/期限、专用验收企微号。先接 5i5ya:`partner=jvs-dw`、`partnerTenant=ruier-grp`;无需提供其 PAC appSecret,不创建 Friday SaaS 生产租户。
## 实施顺序
1. 核对准备记录和索引状态;确认 RDS 备份、网关限制、所有生产配置已齐。
2. 备份现有配置和镜像记录;`main` 快进到本次经过测试的 SHA,保护现有未跟踪文件。
3. 补全 env 草稿、安装配置和私钥;PAC/AI 使用同一服务读取密钥。
4. `COMPOSE_MANAGED=1 bash deploy/deploy-prod.sh --no-pull` 升级 PAC,核验七条迁移及真实读取;不首灌、不回填、不全量重算。
5. `COMPOSE_MANAGED=1 bash deploy/deploy-ai.sh production --no-pull`。
6. `COMPOSE_MANAGED=1 bash deploy/deploy-ingress.sh production`;检查健康,启用网关和巡检。
7. CLI 开户,配置组织/员工范围及席位;验收 5i5ya 患者数据和诊所隔离。
8. 先开 worker、后按业务确认开 scheduler;专用号验收收发/图片/文件及 AI 停服补投,再开放正式业务。
**生产发布仍有短暂停服;本次没有承诺零停机。** 中转保护 AI 停服期间已持久接收的回调,不覆盖中转、主机或数据库自身故障。首次上线不能照搬测试账号、设备或密钥。
......@@ -196,3 +196,13 @@ docker compose -p pac-ai -f docker-compose.ai.yml exec ai-service \
私密保存命令输出的临时密码,打开 `https://fai.jarvismedical.asia/login` 登录并改密。只读账号增加 `--role=platform_viewer`。
已有账号使用 `grant --user='<用户UUID>' --role=platform_admin --reason='开通平台管理'`,继续使用原密码。完整命令见 [平台管理](friday-ai-platform-management.md)。
## 9. 服务器巡检
```sh
bash deploy/check-ai-health.sh
# 中转只构建和预检,不迁移或重启:
bash deploy/deploy-ingress.sh staging --check
```
返回 JSON `status=ok`、退出码 0 为正常;`degraded` / 非 0 需检查对应项。测试保留现有本地调试路由;本地长时间离线也可能造成中转积压,不要因此清队列。生产部署和告警安装见 [生产部署](friday-ai-production-deployment.md)。
# 2026-10-10 FridayAI 生产准备
## 范围
- 功能分支 `codex/production-readiness`;功能分支 → test 验证 → 功能分支 → main。
- 生产现有 PAC 保持运行;不发布生产 AI/中转、不启用生产发信、不改全局时区。
- 移除未启用步骤汇总能力已经完成:main `3b81549`,测试运行 `3cd4acf`。
## 生产核验与准备
- 应用机 `/data/pac`:main `d9a9fda`;现有 PAC 健康。新代码尚未部署。
- RDS PostgreSQL 18.3;PAC 库约 266 GB;现有应用账号无建库/建角色权限。未尝试提升权限。
- RDS 云端剩余存储、备份和 IOPS 无控制台权限确认,列入运维上线门禁。
- 独立生产密钥及未激活配置草稿:`/data/pac-ops/readiness-20261010/config/`;目录 700、文件 600。没有修改 PAC 运行 `.env`。
- 现有配置/镜像/源码快照:`/data/pac-ops/readiness-20261010/`;不含数据库全量备份。
## 索引任务
后台任务、日志、退出码均在上述目录,断开本地不会中断。
首次尝试在 00:20 开始;00:23 连续两次 PAC 健康检查超过五秒,任务按保护逻辑取消。核查同一时段有 00:15 启动的 `incremental_bundle` 正在摄入,不能仅据此归因于索引。随后健康恢复。已确认并删除本次创建的无效索引,未触碰既有索引,首次日志保留在 `attempt-1/`。
调整后不仅看 PostgreSQL 长查询,还检查 `sync_logs`:摄入期间和完成后的 15 分钟不启动;之后需要连续六次健康/空闲采样。每次只建一个,`CONCURRENTLY`,维护内存 64 MB、并行维护 worker 0、锁超时 5 秒、语句超时 45 分钟。最多等待六小时;健康连续失败会取消并停止,不自动循环重试。任务仍需以最终日志和索引有效性查询确认。
提前创建不修改 `_prisma_migrations`。正式发布仍按顺序运行七条迁移:三个索引已有效时跳过重复创建;未启用派生表按历史迁移创建后由新迁移删除,不回填事实。
## 部署与监控验证
- AI 生产端口覆盖和中转生产端口覆盖纳入仓库;两套发布脚本统一支持 `COMPOSE_MANAGED=1`。
- 中转新增 `--check`,不执行迁移/重启;生产拒绝本地账号分流配置。
- 修复 AI 发布最后一次 Compose exec 吞掉 SSH 管道后续命令的问题。
- 新增服务器巡检命令和 cron/logrotate 模板;外部健康脚本支持 AI readiness。
- 测试服务器真实巡检:AI、Web、PAC、ingress 均正常;中转 pending=0、retrying=0,outbox pending=0、expiredLeases=0。未发送任何测试消息。
- 通知需运维接入云日志/云拨测与接收人;仅安装巡检日志不等于已收到告警。
- 本地部署/预检/健康检查测试共 31 项通过;shell 语法检查通过。
## 运维交付
按 [生产部署](../friday-ai-production-deployment.md) 实施。需要运维执行的 SQL、网关、cron、日志轮转模板在 `deploy/production/`。待提供:生产域名/证书,专用数据库账号,ECom/OSS/模型生产配置,告警接收人,首个集团/管理员/诊所/席位信息。无需交付 RDS 管理账号。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment