Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
1b78c5ec
Commit
1b78c5ec
authored
Oct 10, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
Merge verified deployment safeguards and operations handoff into main
parents
877f5339
8010ba3c
Pipeline
#3744
failed in 0 seconds
Changes
14
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
14 changed files
with
342 additions
and
83 deletions
+342
-83
deploy/README.md
+1
-1
deploy/deploy-ai.sh
+8
-0
deploy/deploy-ingress.sh
+6
-0
deploy/deploy-mode.sh
+57
-0
deploy/deploy-prod.sh
+11
-11
deploy/production/friday-ai-gateway.conf.example
+10
-11
deploy/tests/deploy-ai.test.mjs
+32
-1
deploy/tests/deploy-ingress.test.mjs
+37
-4
deploy/tests/deploy-mode.test.mjs
+71
-0
deploy/tests/deploy-pac.test.mjs
+22
-6
docs/friday-ai-production-deployment.md
+23
-18
docs/friday-ai-production-handoff.md
+62
-30
docs/friday-ai-test-deployment.md
+1
-0
docs/qa/2026-10-10-production-readiness.md
+1
-1
No files found.
deploy/README.md
View file @
1b78c5ec
...
@@ -125,7 +125,7 @@ TLS 证书自备(Let's Encrypt / 云证书);域名要和 `.env` 的 `PAC_DOMAIN`
...
@@ -125,7 +125,7 @@ TLS 证书自备(Let's Encrypt / 云证书);域名要和 `.env` 的 `PAC_DOMAIN`
### 托管 DB/Redis(阿里云 RDS + Redis,可选)
### 托管 DB/Redis(阿里云 RDS + Redis,可选)
默认自带 PG/Redis(compose 内置)
。要走托管:①gen-env 时传托管连接串,②部署时加
`COMPOSE_MANAGED=1`
。应用代码零改动。
基础 Compose 默认自带 PG/Redis。当前 main/production 必须显式设置
`COMPOSE_MANAGED=1`
,test/staging 则保持未设置或
`0`
;三个发布入口共用模式检查,错误时在构建/迁移/重启前退出。托管发布完成前还需通过应用机私网端点检查
。要走托管:①gen-env 时传托管连接串,②部署时加
`COMPOSE_MANAGED=1`
。应用代码零改动。
```
bash
```
bash
# ① 生成 env 时指向托管端点(不启自带 PG/Redis)
# ① 生成 env 时指向托管端点(不启自带 PG/Redis)
...
...
deploy/deploy-ai.sh
View file @
1b78c5ec
...
@@ -7,6 +7,8 @@ main() {
...
@@ -7,6 +7,8 @@ main() {
[[
"
$target
"
==
staging
||
"
$target
"
==
production
]]
||
{
echo
'用法:bash deploy/deploy-ai.sh staging|production [--check|--no-pull]'
;
return
1
;
}
[[
"
$target
"
==
staging
||
"
$target
"
==
production
]]
||
{
echo
'用法:bash deploy/deploy-ai.sh staging|production [--check|--no-pull]'
;
return
1
;
}
[[
-z
"
$option
"
||
"
$option
"
==
--check
||
"
$option
"
==
--no-pull
]]
||
{
echo
'未知选项'
;
return
1
;
}
[[
-z
"
$option
"
||
"
$option
"
==
--check
||
"
$option
"
==
--no-pull
]]
||
{
echo
'未知选项'
;
return
1
;
}
cd
"
$(
dirname
"
${
BASH_SOURCE
[0]
}
"
)
/.."
cd
"
$(
dirname
"
${
BASH_SOURCE
[0]
}
"
)
/.."
source
deploy/deploy-mode.sh
check_deploy_mode
"
$target
"
local
branch
=
test
local
branch
=
test
[[
"
$target
"
==
production
]]
&&
branch
=
main
[[
"
$target
"
==
production
]]
&&
branch
=
main
[[
"
$(
git branch
--show-current
)
"
==
"
$branch
"
]]
||
{
echo
"部署要求当前分支为
$branch
"
;
return
1
;
}
[[
"
$(
git branch
--show-current
)
"
==
"
$branch
"
]]
||
{
echo
"部署要求当前分支为
$branch
"
;
return
1
;
}
...
@@ -53,6 +55,12 @@ main() {
...
@@ -53,6 +55,12 @@ main() {
[[
"
$actual
"
==
"
$expected
"
]]
||
{
echo
"
$service
镜像校验失败"
;
return
1
;
}
[[
"
$actual
"
==
"
$expected
"
]]
||
{
echo
"
$service
镜像校验失败"
;
return
1
;
}
done
done
"
${
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))"
"
${
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))"
if
[[
"
${
COMPOSE_MANAGED
:-
0
}
"
==
1
]]
;
then
local
private_ip
private_ip
=
$(
deploy_private_ip
)
verify_deploy_endpoint
"http://
$private_ip
:3104/health/ready"
ready
verify_deploy_endpoint
"http://
$private_ip
:3103/login"
web
fi
printf
'%s\n'
"
$AI_IMAGE_TAG
"
>
"
$state
/current-image-tag"
printf
'%s\n'
"
$AI_IMAGE_TAG
"
>
"
$state
/current-image-tag"
echo
"FridayAI 部署完成:
${
AI_IMAGE_TAG
}
;Web 3103 / API 3104;PAC 未重启"
echo
"FridayAI 部署完成:
${
AI_IMAGE_TAG
}
;Web 3103 / API 3104;PAC 未重启"
}
}
...
...
deploy/deploy-ingress.sh
View file @
1b78c5ec
...
@@ -6,6 +6,8 @@ option=${2:-}
...
@@ -6,6 +6,8 @@ option=${2:-}
[[
-z
"
$option
"
||
"
$option
"
==
--check
]]
||
{
echo
'未知选项'
;
exit
1
;
}
[[
-z
"
$option
"
||
"
$option
"
==
--check
]]
||
{
echo
'未知选项'
;
exit
1
;
}
[[
"
$target
"
==
staging
||
"
$target
"
==
production
]]
||
{
echo
'用法:bash deploy/deploy-ingress.sh staging|production [--check]'
;
exit
1
;
}
[[
"
$target
"
==
staging
||
"
$target
"
==
production
]]
||
{
echo
'用法:bash deploy/deploy-ingress.sh staging|production [--check]'
;
exit
1
;
}
cd
"
$(
dirname
"
${
BASH_SOURCE
[0]
}
"
)
/.."
cd
"
$(
dirname
"
${
BASH_SOURCE
[0]
}
"
)
/.."
source
deploy/deploy-mode.sh
check_deploy_mode
"
$target
"
branch
=
test
branch
=
test
[[
"
$target
"
==
production
]]
&&
branch
=
main
[[
"
$target
"
==
production
]]
&&
branch
=
main
[[
"
$(
git branch
--show-current
)
"
==
"
$branch
"
]]
||
{
echo
"要求当前分支为
$branch
"
;
exit
1
;
}
[[
"
$(
git branch
--show-current
)
"
==
"
$branch
"
]]
||
{
echo
"要求当前分支为
$branch
"
;
exit
1
;
}
...
@@ -31,6 +33,10 @@ id=$("${compose[@]}" ps -q channel-ingress)
...
@@ -31,6 +33,10 @@ id=$("${compose[@]}" ps -q channel-ingress)
expected
=
$(
docker image inspect
"pac-channel-ingress:
$INGRESS_IMAGE_TAG
"
--format
'{{.Id}}'
)
expected
=
$(
docker image inspect
"pac-channel-ingress:
$INGRESS_IMAGE_TAG
"
--format
'{{.Id}}'
)
actual
=
$(
docker inspect
"
$id
"
--format
'{{.Image}}'
)
actual
=
$(
docker inspect
"
$id
"
--format
'{{.Image}}'
)
[[
"
$expected
"
==
"
$actual
"
]]
||
{
echo
'中转镜像校验失败'
;
exit
1
;
}
[[
"
$expected
"
==
"
$actual
"
]]
||
{
echo
'中转镜像校验失败'
;
exit
1
;
}
if
[[
"
${
COMPOSE_MANAGED
:-
0
}
"
==
1
]]
;
then
private_ip
=
$(
deploy_private_ip
)
verify_deploy_endpoint
"http://
$private_ip
:3106/health"
health
fi
mkdir
-p
"
$(
git rev-parse
--git-common-dir
)
/ingress-deploy"
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"
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、未停止旧调试中转"
deploy/deploy-mode.sh
0 → 100644
View file @
1b78c5ec
#!/usr/bin/env bash
# 三套发布入口共用;当前生产采用 RDS + 远端网关,测试采用本机代理。
# ⛔ 不自动开启托管模式:它会开放主机监听地址,必须由部署者明确选择。
check_deploy_mode
()
{
local
target
=
"
$1
"
mode
=
"
${
COMPOSE_MANAGED
:-
0
}
"
case
"
$mode
"
in
0|1
)
;;
*
)
echo
'FAIL: COMPOSE_MANAGED 只能为 0 或 1'
>
&2
;
return
1
;;
esac
case
"
$target
"
in
main|production
)
if
[[
"
$mode
"
!=
1
]]
;
then
echo
'FAIL: 当前生产使用远端网关,必须显式设置 COMPOSE_MANAGED=1;未执行构建、迁移或重启。'
>
&2
return
1
fi
;;
test
|
staging
)
if
[[
"
$mode
"
!=
0
]]
;
then
echo
'FAIL: 当前测试使用本机代理,请取消 COMPOSE_MANAGED 或设为 0,避免开放应用端口。'
>
&2
return
1
fi
;;
esac
}
# 在应用已通过容器健康检查后,另验主机私网路径。不能代替网关/安全组验收。
deploy_private_ip
()
{
local
addr
addr
=
$(
ip
-4
route get 1 2>/dev/null | awk
'{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1);exit}}'
)
||
true
if
[[
-z
"
$addr
"
||
"
$addr
"
==
127.
*
||
"
$addr
"
==
0.0.0.0
]]
;
then
echo
'FAIL: 无法确定应用机私网地址,不能验证网关上游'
>
&2
return
1
fi
printf
'%s'
"
$addr
"
}
# $1 URL,$2 web|health|ready;只接受 HTTP 200,健康接口必须核验 JSON 状态。
verify_deploy_endpoint
()
{
local
url
=
"
$1
"
kind
=
"
$2
"
out code body
out
=
$(
curl
--noproxy
'*'
-sS
--connect-timeout
3
--max-time
10
-w
'\n%{http_code}'
"
$url
"
)
||
{
echo
"FAIL: 私网端点不可达:
${
url
}
;请检查 managed 端口绑定"
>
&2
return
1
}
code
=
${
out
##*
$'
\n
'
}
body
=
${
out
%
$'
\n
'
*
}
if
[[
"
$code
"
!=
200
]]
;
then
echo
"FAIL: 私网端点
$url
返回 HTTP
$code
"
>
&2
return
1
fi
if
[[
"
$kind
"
!=
web
]]
&&
!
grep
-Eq
'"status"[[:space:]]*:[[:space:]]*"ok"'
<<<
"
$body
"
;
then
echo
"FAIL: 私网健康检查不通过:
$url
"
>
&2
return
1
fi
if
[[
"
$kind
"
==
ready
]]
&&
!
grep
-Eq
'"db"[[:space:]]*:[[:space:]]*"up"'
<<<
"
$body
"
;
then
echo
"FAIL: 私网数据库就绪检查不通过:
$url
"
>
&2
return
1
fi
echo
"私网端点通过:
$url
"
}
deploy/deploy-prod.sh
View file @
1b78c5ec
...
@@ -15,9 +15,10 @@
...
@@ -15,9 +15,10 @@
# 故生产机停在 main、测试机停在 test 即可;临时切别的分支用 DEPLOY_BRANCH 覆盖。
# 故生产机停在 main、测试机停在 test 即可;临时切别的分支用 DEPLOY_BRANCH 覆盖。
#
#
# 用法(服务器上,~/pac):
# 用法(服务器上,~/pac):
# bash deploy/deploy-prod.sh # 部署当前分支(生产机=main / 测试机=test)
# COMPOSE_MANAGED=1 bash deploy/deploy-prod.sh # 生产(main,远端网关)
# bash deploy/deploy-prod.sh # 测试(test,本机代理)
# DEPLOY_BRANCH=test bash deploy/deploy-prod.sh # 显式指定分支(首次把某机切到该分支)
# DEPLOY_BRANCH=test bash deploy/deploy-prod.sh # 显式指定分支(首次把某机切到该分支)
#
bash deploy/deploy-prod.sh --no-pull #
跳过 git 拉取(代码已就位时)
#
COMPOSE_MANAGED=1 bash deploy/deploy-prod.sh --no-pull # 生产
跳过 git 拉取(代码已就位时)
set
-euo
pipefail
set
-euo
pipefail
log
()
{
printf
'\n\033[1;36m== %s ==\033[0m\n'
"
$*
"
;
}
log
()
{
printf
'\n\033[1;36m== %s ==\033[0m\n'
"
$*
"
;
}
...
@@ -43,6 +44,10 @@ probe_health() { # $1=base(如 http://127.0.0.1:3101) → stdout "<code> <body
...
@@ -43,6 +44,10 @@ probe_health() { # $1=base(如 http://127.0.0.1:3101) → stdout "<code> <body
main
()
{
main
()
{
cd
"
$(
dirname
"
$0
"
)
/.."
cd
"
$(
dirname
"
$0
"
)
/.."
source
deploy/deploy-mode.sh
local
BRANCH
=
"
${
DEPLOY_BRANCH
:-$(
git rev-parse
--abbrev-ref
HEAD
)}
"
check_deploy_mode
"
$BRANCH
"
# 与 FridayAI 共用 checkout,发布期间禁止另一套流程修改源码或构建。
# 与 FridayAI 共用 checkout,发布期间禁止另一套流程修改源码或构建。
exec
9>
"
$(
git rev-parse
--git-common-dir
)
/deploy.lock"
exec
9>
"
$(
git rev-parse
--git-common-dir
)
/deploy.lock"
flock
-n
9
||
die
"PAC 或 FridayAI 正在部署"
flock
-n
9
||
die
"PAC 或 FridayAI 正在部署"
...
@@ -84,8 +89,6 @@ main() {
...
@@ -84,8 +89,6 @@ main() {
# 部署哪个分支:规约 main=生产 / test=测试。默认跟随本机当前 checkout 的分支(不写死),
# 部署哪个分支:规约 main=生产 / test=测试。默认跟随本机当前 checkout 的分支(不写死),
# 故生产机(在 main)自然部署 main、测试机(在 test)自然部署 test;需临时切分支再用 DEPLOY_BRANCH 覆盖。
# 故生产机(在 main)自然部署 main、测试机(在 test)自然部署 test;需临时切分支再用 DEPLOY_BRANCH 覆盖。
local
BRANCH
=
"
${
DEPLOY_BRANCH
:-$(
git rev-parse
--abbrev-ref
HEAD
)}
"
if
[[
"
${
1
:-}
"
!=
"--no-pull"
]]
;
then
if
[[
"
${
1
:-}
"
!=
"--no-pull"
]]
;
then
log
"拉取分支
$BRANCH
(fetch + checkout + ff-only)"
log
"拉取分支
$BRANCH
(fetch + checkout + ff-only)"
git fetch origin
"
$BRANCH
"
git fetch origin
"
$BRANCH
"
...
@@ -182,13 +185,10 @@ main() {
...
@@ -182,13 +185,10 @@ main() {
# 这里额外验私网 IP 可达,把"loopback 通、网关不通"的漂移在部署当场拦下(2026-07-18 踩过)。
# 这里额外验私网 IP 可达,把"loopback 通、网关不通"的漂移在部署当场拦下(2026-07-18 踩过)。
if
[[
"
${
COMPOSE_MANAGED
:-
0
}
"
==
"1"
]]
;
then
if
[[
"
${
COMPOSE_MANAGED
:-
0
}
"
==
"1"
]]
;
then
log
"验证 3b:私网 IP 可达(网关路径,防 loopback-only 绑定)"
log
"验证 3b:私网 IP 可达(网关路径,防 loopback-only 绑定)"
local
priv phealth pweb
local
priv
priv
=
$(
ip route get 1 2>/dev/null | awk
'{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1);exit}}'
)
priv
=
$(
deploy_private_ip
)
[[
-n
"
$priv
"
]]
||
die
"拿不到私网 IP(ip route get 1 解析失败)"
verify_deploy_endpoint
"http://
$priv
:3101/health"
health
phealth
=
$(
probe_health
"http://
$priv
:3101"
)
||
die
"私网
$priv
:3101 /health 不健康(
$phealth
)— 应用可能绑成 loopback,网关够不到(会 502)。托管环境需 web/service 绑 0.0.0.0(见 docker-compose.managed.yml)"
verify_deploy_endpoint
"http://
$priv
:3100/plans"
web
pweb
=
$(
curl
-s
-m
30
-o
/dev/null
-w
'%{http_code}'
"http://
$priv
:3100/plans"
||
true
)
[[
"
$pweb
"
==
"200"
]]
||
die
"私网
$priv
:3100 web=
$pweb
— 同上(loopback 绑定 → 网关 502)"
echo
" 私网
$priv
health 200 / web 200(网关路径 OK)"
fi
fi
log
"部署成功 ✅
$(
git log
--oneline
-1
)
"
log
"部署成功 ✅
$(
git log
--oneline
-1
)
"
...
...
deploy/production/friday-ai-gateway.conf.example
View file @
1b78c5ec
# 在现有生产网关安装。
替换 AI_DOMAIN、RELAY_DOMAIN、
证书路径后 nginx -t。
# 在现有生产网关安装。
域名统一为 fai.friday.tech;填写实际
证书路径后 nginx -t。
# 前提:只有此网关可访问应用机 3103/3104/3106;真实来源仅一层代理。
# 前提:只有此网关可访问应用机 3103/3104/3106;真实来源仅一层代理。
server {
server {
listen 80;
listen 80;
server_name
AI_DOMAIN RELAY_DOMAIN
;
server_name
fai.friday.tech
;
return 301 https://$host$request_uri;
return 301 https://$host$request_uri;
}
}
server {
server {
listen 443 ssl;
listen 443 ssl;
server_name
AI_DOMAIN
;
server_name
fai.friday.tech
;
ssl_certificate /REQUIRED/AI/fullchain.pem;
ssl_certificate /REQUIRED/AI/fullchain.pem;
ssl_certificate_key /REQUIRED/AI/privkey.pem;
ssl_certificate_key /REQUIRED/AI/privkey.pem;
client_max_body_size 25m;
client_max_body_size 25m;
...
@@ -27,13 +27,7 @@ server {
...
@@ -27,13 +27,7 @@ server {
}
}
location = /health { proxy_pass http://172.16.4.239:3104; access_log 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 = /health/ready { proxy_pass http://172.16.4.239:3104; access_log off; }
location / { proxy_pass http://172.16.4.239:3103; }
# 回调直接交给独立中转,AI 停服时仍可接收。
}
server {
listen 443 ssl;
server_name RELAY_DOMAIN;
ssl_certificate /REQUIRED/RELAY/fullchain.pem;
ssl_certificate_key /REQUIRED/RELAY/privkey.pem;
location = /hook/ecom {
location = /hook/ecom {
limit_except POST { deny all; }
limit_except POST { deny all; }
client_max_body_size 20m;
client_max_body_size 20m;
...
@@ -44,5 +38,10 @@ server {
...
@@ -44,5 +38,10 @@ server {
proxy_connect_timeout 2s;
proxy_connect_timeout 2s;
proxy_read_timeout 8s;
proxy_read_timeout 8s;
}
}
location / { return 404; }
# 不暴露中转的本地调试和管理入口。
location = /local { return 404; }
location ^~ /local/ { return 404; }
location = /status { return 404; }
location ^~ /hook/ { return 404; }
location / { proxy_pass http://172.16.4.239:3103; }
}
}
deploy/tests/deploy-ai.test.mjs
View file @
1b78c5ec
...
@@ -15,12 +15,14 @@ function runDeploy({
...
@@ -15,12 +15,14 @@ function runDeploy({
piped = false,
piped = false,
managed = false,
managed = false,
target = 'staging',
target = 'staging',
privateFail = false,
} = {}) {
} = {}) {
const root = mkdtempSync(join(tmpdir(), 'ai-deploy-flow-'));
const root = mkdtempSync(join(tmpdir(), 'ai-deploy-flow-'));
const bin = join(root, 'bin'),
const bin = join(root, 'bin'),
log = join(root, 'commands.log');
log = join(root, 'commands.log');
for (const dir of [bin, join(root, '.git'), join(root, 'deploy')]) mkdirSync(dir);
for (const dir of [bin, join(root, '.git'), join(root, 'deploy')]) mkdirSync(dir);
copyFileSync(source, join(root, 'deploy/deploy-ai.sh'));
copyFileSync(source, join(root, 'deploy/deploy-ai.sh'));
copyFileSync(resolve('deploy/deploy-mode.sh'), join(root, 'deploy/deploy-mode.sh'));
writeFileSync(log, '');
writeFileSync(log, '');
const common = 'printf "%s\\n" "$(basename "$0") $*" >> "$DEPLOY_TEST_LOG"\n';
const common = 'printf "%s\\n" "$(basename "$0") $*" >> "$DEPLOY_TEST_LOG"\n';
const stubs = {
const stubs = {
...
@@ -30,6 +32,9 @@ function runDeploy({
...
@@ -30,6 +32,9 @@ function runDeploy({
'rev-parse --git-common-dir') echo .git ;;
'rev-parse --git-common-dir') echo .git ;;
esac`,
esac`,
flock: 'exit 0',
flock: 'exit 0',
ip: 'echo "1 via 172.16.4.1 dev eth0 src 172.16.4.239"',
curl: `[[ "$PRIVATE_FAIL" != 1 ]] || exit 7
printf '{"status":"ok","db":"up"}\\n200'`,
docker: String.raw`if [[ ( "$*" == *' run '* || "$*" == *' exec '* ) && "$*" != *'--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
if [[ -n "$DEPLOY_TEST_FAIL" && "$*" == *"$DEPLOY_TEST_FAIL"* ]]; then exit 43; fi
case "$*" in
case "$*" in
...
@@ -62,7 +67,8 @@ function runDeploy({
...
@@ -62,7 +67,8 @@ function runDeploy({
...process.env,
...process.env,
PATH: `${bin}:${process.env.PATH}`,
PATH: `${bin}:${process.env.PATH}`,
DEPLOY_TEST_LOG: log,
DEPLOY_TEST_LOG: log,
COMPOSE_MANAGED: managed ? '1' : '0',
COMPOSE_MANAGED: typeof managed === 'string' ? managed : managed ? '1' : '0',
PRIVATE_FAIL: privateFail ? '1' : '0',
DEPLOY_TEST_FAIL: fail,
DEPLOY_TEST_FAIL: fail,
DEPLOY_TEST_BRANCH: branch,
DEPLOY_TEST_BRANCH: branch,
DEPLOY_TEST_MISMATCH: mismatch ? '1' : '0',
DEPLOY_TEST_MISMATCH: mismatch ? '1' : '0',
...
@@ -141,3 +147,28 @@ test('生产托管模式每个 Compose 操作均加载生产端口覆盖', () =>
...
@@ -141,3 +147,28 @@ test('生产托管模式每个 Compose 操作均加载生产端口覆盖', () =>
.every((c) => c.includes('-f docker-compose.ai.managed.yml')),
.every((c) => c.includes('-f docker-compose.ai.managed.yml')),
);
);
});
});
for (const managed of [false, '', 'true']) {
test(`生产未明确开启托管模式时提前拒绝: ${managed}`, () => {
const { result, commands } = runDeploy({ target: 'production', branch: 'main', managed });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /COMPOSE_MANAGED/);
assert.ok(!commands.some((c) => /^(docker|flock) |git fetch|git merge/.test(c)));
});
}
test('测试误开托管模式时提前拒绝', () => {
const { result, commands } = runDeploy({ managed: true });
assert.notEqual(result.status, 0);
assert.ok(!commands.some((c) => c.startsWith('docker ')));
});
test('AI 容器健康但私网不可达时不能报告部署完成', () => {
const { result } = runDeploy({
target: 'production',
branch: 'main',
managed: true,
privateFail: true,
});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /私网端点不可达/);
assert.doesNotMatch(result.stdout, /FridayAI 部署完成/);
});
deploy/tests/deploy-ingress.test.mjs
View file @
1b78c5ec
...
@@ -4,30 +4,42 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, rmSy
...
@@ -4,30 +4,42 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, rmSy
import { tmpdir } from 'node:os';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { spawnSync } from 'node:child_process';
function run({ check = false, fail = '' } = {}) {
function run({
check = false,
fail = '',
managed = '1',
privateFail = false,
target = 'production',
} = {}) {
const root = mkdtempSync(join(tmpdir(), 'ingress-deploy-'));
const root = mkdtempSync(join(tmpdir(), 'ingress-deploy-'));
try {
try {
for (const d of ['deploy', 'bin', '.git']) mkdirSync(join(root, d));
for (const d of ['deploy', 'bin', '.git']) mkdirSync(join(root, d));
copyFileSync(resolve('deploy/deploy-ingress.sh'), join(root, 'deploy/deploy-ingress.sh'));
copyFileSync(resolve('deploy/deploy-ingress.sh'), join(root, 'deploy/deploy-ingress.sh'));
copyFileSync(resolve('deploy/deploy-mode.sh'), join(root, 'deploy/deploy-mode.sh'));
const log = join(root, 'log');
const log = join(root, 'log');
writeFileSync(log, '');
writeFileSync(log, '');
const scripts = {
const scripts = {
git: `case "$*" in 'branch --show-current') echo
main
;; 'rev-parse --git-common-dir') echo .git;; 'rev-parse --short HEAD') echo abc123;; esac`,
git: `case "$*" in 'branch --show-current') echo
"$BRANCH"
;; 'rev-parse --git-common-dir') echo .git;; 'rev-parse --short HEAD') echo abc123;; esac`,
flock: 'exit 0',
flock: 'exit 0',
ip: 'echo "1 via 172.16.4.1 dev eth0 src 172.16.4.239"',
curl: `[[ "$PRIVATE_FAIL" != 1 ]] || exit 7
printf '{"status":"ok","db":"up"}\\n200'`,
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`,
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))
for (const [n, s] of Object.entries(scripts))
writeFileSync(join(root, 'bin', n), '#!/bin/bash\nset -eu\n' + s, { mode: 0o755 });
writeFileSync(join(root, 'bin', n), '#!/bin/bash\nset -eu\n' + s, { mode: 0o755 });
const r = spawnSync(
const r = spawnSync(
'bash',
'bash',
[join(root, 'deploy/deploy-ingress.sh'),
'production'
, ...(check ? ['--check'] : [])],
[join(root, 'deploy/deploy-ingress.sh'),
target
, ...(check ? ['--check'] : [])],
{
{
encoding: 'utf8',
encoding: 'utf8',
timeout: 10000,
timeout: 10000,
env: {
env: {
...process.env,
...process.env,
PATH: join(root, 'bin') + ':' + process.env.PATH,
PATH: join(root, 'bin') + ':' + process.env.PATH,
COMPOSE_MANAGED: '1',
COMPOSE_MANAGED: managed,
PRIVATE_FAIL: privateFail ? '1' : '0',
BRANCH: target === 'production' ? 'main' : 'test',
LOG: log,
LOG: log,
FAIL: fail,
FAIL: fail,
},
},
...
@@ -64,3 +76,24 @@ test('中转路由校验失败保持原容器,成功后才切换镜像', () =>
...
@@ -64,3 +76,24 @@ test('中转路由校验失败保持原容器,成功后才切换镜像', () =>
assert.ok(good.log.indexOf('verifyRouting') < good.log.indexOf('up -d'));
assert.ok(good.log.indexOf('verifyRouting') < good.log.indexOf('up -d'));
assert.doesNotMatch(good.log, /stop ai/);
assert.doesNotMatch(good.log, /stop ai/);
});
});
for (const managed of ['', '0', 'true']) {
test(`生产中转漏配或错配托管模式时不调用 Docker: ${managed}`, () => {
const { r, log } = run({ managed });
assert.notEqual(r.status, 0);
assert.match(r.stderr, /COMPOSE_MANAGED/);
assert.equal(log, '');
});
}
test('测试中转本机模式正常,误开托管模式拒绝', () => {
assert.equal(run({ target: 'staging', managed: '0', check: true }).r.status, 0);
const { r, log } = run({ target: 'staging' });
assert.notEqual(r.status, 0);
assert.equal(log, '');
});
test('中转私网不通时不能报告部署完成', () => {
const { r } = run({ privateFail: true });
assert.notEqual(r.status, 0);
assert.match(r.stderr, /私网端点不可达/);
assert.doesNotMatch(r.stdout, /中转部署完成/);
});
deploy/tests/deploy-mode.test.mjs
0 → 100644
View file @
1b78c5ec
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, copyFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
const helper = resolve('deploy/deploy-mode.sh');
function probe(body, code = '200', kind = 'ready') {
const root = mkdtempSync(join(tmpdir(), 'deploy-probe-'));
try {
writeFileSync(join(root, 'curl'), '#!/bin/sh\nprintf "%s\\n%s" "$BODY" "$CODE"\n', {
mode: 0o755,
});
return spawnSync(
'bash',
[
'-c',
'source "$1"; verify_deploy_endpoint http://172.16.4.239:3104/health/ready "$2"',
'test',
helper,
kind,
],
{
encoding: 'utf8',
timeout: 5000,
env: { ...process.env, PATH: root + ':' + process.env.PATH, BODY: body, CODE: code },
},
);
} finally {
rmSync(root, { recursive: true, force: true });
}
}
test('健康检查验证业务状态,不能把 HTTP 200 错误页视为成功', () => {
assert.equal(probe('{"status": "ok", "db": "up"}').status, 0);
for (const body of ['{"code":10004}', '{"status":"ok","db":"down"}', '
<html>
error
</html>
'])
assert.notEqual(probe(body).status, 0);
assert.notEqual(probe('{"status":"ok","db":"up"}', '502').status, 0);
assert.equal(probe('{"status":"ok"}', '200', 'health').status, 0);
assert.equal(probe('
<html>
login
</html>
', '200', 'web').status, 0);
});
test('PAC main 分支漏配托管模式,即使没有 env,也在 Docker 和发布锁之前拒绝', () => {
const root = mkdtempSync(join(tmpdir(), 'pac-mode-'));
try {
for (const d of ['deploy', 'bin']) mkdirSync(join(root, d));
for (const f of ['deploy-prod.sh', 'deploy-mode.sh'])
copyFileSync(resolve('deploy', f), join(root, 'deploy', f));
writeFileSync(join(root, 'bin/git'), '#!/bin/sh\necho main\n', { mode: 0o755 });
for (const f of ['docker', 'flock'])
writeFileSync(join(root, 'bin', f), '#!/bin/sh\necho called >> "$LOG"\nexit 99\n', {
mode: 0o755,
});
const log = join(root, 'log');
writeFileSync(log, '');
const env = { ...process.env, PATH: join(root, 'bin') + ':' + process.env.PATH, LOG: log };
delete env.COMPOSE_MANAGED;
delete env.DEPLOY_BRANCH;
const r = spawnSync('bash', [join(root, 'deploy/deploy-prod.sh'), '--no-pull'], {
env,
encoding: 'utf8',
timeout: 5000,
});
assert.notEqual(r.status, 0);
assert.match(r.stderr, /COMPOSE_MANAGED=1/);
assert.equal(readFileSync(log, 'utf8'), '');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
deploy/tests/deploy-pac.test.mjs
View file @
1b78c5ec
...
@@ -8,22 +8,38 @@ import { spawnSync } from 'node:child_process';
...
@@ -8,22 +8,38 @@ import { spawnSync } from 'node:child_process';
test('PAC 迁移失败保留应用和已运行的基础设施', () => {
test('PAC 迁移失败保留应用和已运行的基础设施', () => {
const root = mkdtempSync(join(tmpdir(), 'pac-migration-failure-'));
const root = mkdtempSync(join(tmpdir(), 'pac-migration-failure-'));
try {
try {
for (const d of ['bin', '.git', 'deploy', 'apps/pac-service']) mkdirSync(join(root, d), { recursive: true });
for (const d of ['bin', '.git', 'deploy', 'apps/pac-service'])
mkdirSync(join(root, d), { recursive: true });
copyFileSync(resolve('deploy/deploy-prod.sh'), join(root, 'deploy/deploy-prod.sh'));
copyFileSync(resolve('deploy/deploy-prod.sh'), join(root, 'deploy/deploy-prod.sh'));
writeFileSync(join(root, 'apps/pac-service/.env'), 'DATABASE_URL=postgresql://dev:dev@localhost:5432/pac\n');
copyFileSync(resolve('deploy/deploy-mode.sh'), join(root, 'deploy/deploy-mode.sh'));
writeFileSync(
join(root, 'apps/pac-service/.env'),
'DATABASE_URL=postgresql://dev:dev@localhost:5432/pac\n',
);
const log = join(root, 'calls');
const log = join(root, 'calls');
writeFileSync(log, '');
writeFileSync(log, '');
for (const [name, body] of Object.entries({
for (const [name, body] of Object.entries({
git: 'case "$*" in "rev-parse --git-common-dir") echo .git;; "rev-parse --abbrev-ref HEAD") echo test;; esac',
git: 'case "$*" in "rev-parse --git-common-dir") echo .git;; "rev-parse --abbrev-ref HEAD") echo test;; esac',
flock: 'exit 0',
flock: 'exit 0',
docker: 'echo "$*" >> "$TEST_CALLS"\ncase "$*" in *"ps -q --status running"*) echo existing;; *"run --rm --no-deps -T --interactive=false pac-migrate"*) exit 42;; esac',
docker:
})) writeFileSync(join(root, 'bin', name), '#!/bin/sh\n' + body + '\n', { mode: 0o755 });
'echo "$*" >> "$TEST_CALLS"\ncase "$*" in *"ps -q --status running"*) echo existing;; *"run --rm --no-deps -T --interactive=false pac-migrate"*) exit 42;; esac',
}))
writeFileSync(join(root, 'bin', name), '#!/bin/sh\n' + body + '\n', { mode: 0o755 });
const r = spawnSync('bash', ['deploy/deploy-prod.sh', '--no-pull'], {
const r = spawnSync('bash', ['deploy/deploy-prod.sh', '--no-pull'], {
cwd: root, encoding: 'utf8', env: { ...process.env, COMPOSE_MANAGED: '0', PATH: join(root, 'bin') + ':' + process.env.PATH, TEST_CALLS: log },
cwd: root,
encoding: 'utf8',
env: {
...process.env,
COMPOSE_MANAGED: '0',
PATH: join(root, 'bin') + ':' + process.env.PATH,
TEST_CALLS: log,
},
});
});
assert.equal(r.status, 42, r.stdout + r.stderr);
assert.equal(r.status, 42, r.stdout + r.stderr);
const calls = readFileSync(log, 'utf8');
const calls = readFileSync(log, 'utf8');
assert.match(calls, /run --rm --no-deps -T --interactive=false pac-migrate/);
assert.match(calls, /run --rm --no-deps -T --interactive=false pac-migrate/);
assert.doesNotMatch(calls, /\b(up|stop|restart|rm)\b.*(pac-service|pac-web|postgres|redis)/);
assert.doesNotMatch(calls, /\b(up|stop|restart|rm)\b.*(pac-service|pac-web|postgres|redis)/);
} finally { rmSync(root, { recursive: true, force: true }); }
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
docs/friday-ai-production-deployment.md
View file @
1b78c5ec
...
@@ -18,22 +18,22 @@
...
@@ -18,22 +18,22 @@
| RDS 规模 | 统计估算约 122 万患者、4,732 万事实、4,722 万事务;连接当时 42 / 820。未执行全表计数 |
| RDS 规模 | 统计估算约 122 万患者、4,732 万事实、4,722 万事务;连接当时 42 / 820。未执行全表计数 |
| 基础工具 | Compose 2.32.4,满足 raw env / override 要求;主机未发现
`psql`
、
`nginx`
、
`jq`
。Node/pnpm 随镜像提供,不需要安装到主机 |
| 基础工具 | Compose 2.32.4,满足 raw env / override 要求;主机未发现
`psql`
、
`nginx`
、
`jq`
。Node/pnpm 随镜像提供,不需要安装到主机 |
生产仍使用同一份
`/data/pac`
源码,三个 Compose 项目独立发布。生产命令统一加
`COMPOSE_MANAGED=1`
,加载仓库中的私网端口覆盖;测试环境不加。无需主机 Node/pnpm。
生产仍使用同一份
`/data/pac`
源码,三个 Compose 项目独立发布。生产命令统一加
`COMPOSE_MANAGED=1`
,加载仓库中的私网端口覆盖;测试环境不加。
三个部署入口均在构建/迁移/停服务前检查:生产必须显式设为
`1`
,测试拒绝设为
`1`
,其他非法值直接退出;不自动切换模式。部署后还校验应用机私网端点,失败不会报告部署成功,但仍需运维验证网关和安全组。
无需主机 Node/pnpm。
准备执行记录见
[
上线准备验收
](
qa/2026-10-10-production-readiness.md
)
。
本手册中的域名、账号和供应商值是待运维填写项,不能直接用测试值替代
。
准备执行记录见
[
上线准备验收
](
qa/2026-10-10-production-readiness.md
)
。
生产域名已确定为
`fai.friday.tech`
,DNS 与证书待运维配置。OSS 和模型凭据按已确认方案复用测试值,其余账号及供应商配置仍需分别核实
。
## 2. 发布前交付清单
## 2. 发布前交付清单
| 提供方 | 必需资料 / 动作 |
| 提供方 | 必需资料 / 动作 |
| --- | --- |
| --- | --- |
| 运维:域名 |
确认 AI 生产域名与回调域名,例如
`fai.friday.tech`
、
`relay.friday.tech`
(仅建议,尚未确认);DNS、HTTPS 证书、网关配置权限
|
| 运维:域名 |
为
`fai.friday.tech`
配置 DNS 指向现有生产网关,签发 HTTPS 证书;AI 与 ECom 回调共用此域名
|
| 运维:网络 | 网关到应用机私网 IP 的 3103 / 3104 / 3106;仅允许网关来源,禁止公网直连。核对真实代理层数;确保应用机容器可访问 RDS、ECom、OSS、模型服务 |
| 运维:网络 | 网关到应用机私网 IP 的 3103 / 3104 / 3106;仅允许网关来源,禁止公网直连。核对真实代理层数;确保应用机容器可访问 RDS、ECom、OSS、模型服务 |
| 运维:RDS | 在现有实例建独立
`friday_ai`
库和专用账号;当前
`pac`
用户没有 CREATEDB / CREATEROLE 权限。由有权限人员操作,不复用 PAC 数据库账号 |
| 运维:RDS | 在现有实例建独立
`friday_ai`
库和专用账号;当前
`pac`
用户没有 CREATEDB / CREATEROLE 权限。由有权限人员操作,不复用 PAC 数据库账号 |
| 运维:备份 | PAC 发布前可恢复的 RDS 备份;AI 上线后同样纳入自动备份和恢复演练。三个大表索引已经建好;仍需确认上线后的 RDS 磁盘、IOPS 余量与发布窗口(本次没有云控制台权限核查) |
| 运维:备份 | PAC 发布前可恢复的 RDS 备份;AI 上线后同样纳入自动备份和恢复演练。三个大表索引已经建好;仍需确认上线后的 RDS 磁盘、IOPS 余量与发布窗口(本次没有云控制台权限核查) |
| 业务 / ECom | 生产 API 地址、accessId、accessSecret、回调出口 IP、生产回调 URL 登记;确认凭据/回调能与测试隔离,不能把现有测试回调直接改成生产 |
| 业务 / ECom | 生产 API 地址、accessId、accessSecret、回调出口 IP、生产回调 URL 登记;确认凭据/回调能与测试隔离,不能把现有测试回调直接改成生产 |
| 业务 / ECom | 首个验收企微号、收费/设备额度及归属企业;不能与本地/测试同时托管同一号,不能在重试时重复申请设备 |
| 业务 / ECom | 首个验收企微号、收费/设备额度及归属企业;不能与本地/测试同时托管同一号,不能在重试时重复申请设备 |
| 运维:OSS |
生产 region、私有 bucket、RAM 凭据;允许必要读写及签名下载,前缀
`friday-ai-prod`
。不复制测试附件和密钥
|
| 运维:OSS |
复用测试 region、bucket 与 AccessKey;生产前缀
`friday-ai-prod`
,对象保持 private;确认授权覆盖新前缀、生命周期规则不误删生产附件
|
| 模型 |
生产模型 API Key、额度;代码默认阿里云兼容接口 /
`qwen3.8-max`
。需确认该账号有模型访问权限;换端点/型号时显式配置
|
| 模型 |
复用测试百炼 API Key 与
`qwen3.8-max`
,两环境共享账号模型配额;上线前核实实际额度,测试避免批量压测
|
| 实施:客户 | 集团名称、首位客户管理员姓名/手机号、首批诊所及对应 PAC 节点、企微绑定员工、席位数量和期限 |
| 实施:客户 | 集团名称、首位客户管理员姓名/手机号、首批诊所及对应 PAC 节点、企微绑定员工、席位数量和期限 |
| 实施:平台 | 平台管理员姓名/手机号;生产使用正式资料,不默认复制测试的“小克”或本地账号 |
| 实施:平台 | 平台管理员姓名/手机号;生产使用正式资料,不默认复制测试的“小克”或本地账号 |
...
@@ -139,13 +139,13 @@ AI_ECOM_ACCESS_ID=<生产凭据>
...
@@ -139,13 +139,13 @@ AI_ECOM_ACCESS_ID=<生产凭据>
AI_ECOM_ACCESS_SECRET=<生产凭据>
AI_ECOM_ACCESS_SECRET=<生产凭据>
AI_INGRESS_TOKEN=<ingress.key内容>
AI_INGRESS_TOKEN=<ingress.key内容>
AI_TRUST_PROXY_HOPS=1
AI_TRUST_PROXY_HOPS=1
AI_LLM_API_KEY=<
生产
模型密钥>
AI_LLM_API_KEY=<
与测试共用的
模型密钥>
AI_OUTBOX_WORKER=off
AI_OUTBOX_WORKER=off
AI_SCHEDULER=off
AI_SCHEDULER=off
AI_OSS_REGION=<
生产
region>
AI_OSS_REGION=<
与测试相同的
region>
AI_OSS_BUCKET=<
生产
bucket>
AI_OSS_BUCKET=<
与测试相同的
bucket>
AI_OSS_ACCESS_KEY_ID=<
生产
OSS凭据>
AI_OSS_ACCESS_KEY_ID=<
与测试共用的
OSS凭据>
AI_OSS_ACCESS_KEY_SECRET=<
生产
OSS凭据>
AI_OSS_ACCESS_KEY_SECRET=<
与测试共用的
OSS凭据>
AI_OSS_PREFIX=friday-ai-prod
AI_OSS_PREFIX=friday-ai-prod
```
```
...
@@ -243,13 +243,13 @@ ingress() { docker compose -p pac-ingress -f docker-compose.ingress.yml \
...
@@ -243,13 +243,13 @@ ingress() { docker compose -p pac-ingress -f docker-compose.ingress.yml \
| 生产域名 | 路由 | 上游 |
| 生产域名 | 路由 | 上游 |
| --- | --- | --- |
| --- | --- | --- |
|
确认后的 AI 域名
|
`/api/`
、
`/health`
、
`/health/ready`
|
`http://172.16.4.239:3104`
|
|
`fai.friday.tech`
|
`/api/`
、
`/health`
、
`/health/ready`
|
`http://172.16.4.239:3104`
|
| 同上 |
`/`
|
`http://172.16.4.239:3103`
|
| 同上 |
`/`
|
`http://172.16.4.239:3103`
|
|
确认后的回调域名
| 仅
`POST /hook/ecom`
|
`http://172.16.4.239:3106`
|
|
`fai.friday.tech`
| 仅
`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 不动。
完整模板:
`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`
或
调试观察台:
在同一个
`fai.friday.tech`
HTTPS server 中,精确匹配回调路径并直接转发到中转,不经过 AI 服务。生产不开放
`/local/*`
、
`/status`
或中转
调试观察台:
```
nginx
```
nginx
location
=
/hook/ecom
{
location
=
/hook/ecom
{
...
@@ -262,12 +262,17 @@ location = /hook/ecom {
...
@@ -262,12 +262,17 @@ location = /hook/ecom {
proxy_connect_timeout
2s
;
proxy_connect_timeout
2s
;
proxy_read_timeout
8s
;
proxy_read_timeout
8s
;
}
}
location
/
{
return
404
;
}
location
=
/local
{
return
404
;
}
location
^~
/local/
{
return
404
;
}
location
=
/status
{
return
404
;
}
location
^~
/hook/
{
return
404
;
}
# 页面仍由 Web 服务承接,完整配置见模板。
location
/
{
proxy_pass
http://172.16.4.239:3103
;
}
```
```
网关覆盖客户端自填来源头;如网关前还有 LB/CDN,须先准确恢复可信来源 IP,再按真实代理层数配置 AI。不能把网关 IP 加入 ECom 供应商白名单来“解决”鉴权失败。
网关覆盖客户端自填来源头;如网关前还有 LB/CDN,须先准确恢复可信来源 IP,再按真实代理层数配置 AI。不能把网关 IP 加入 ECom 供应商白名单来“解决”鉴权失败。
运维
`nginx -t`
成功后 reload;验证 HTTPS
`/login`
、
`/health/ready`
(JSON
`status=ok`
、
`db=up`
)及 PAC
`/health`
。ECom
回调
必须等新中转就绪后再登记,保留测试
`relay.jarvismedical.asia`
的用途与配置。
运维
`nginx -t`
成功后 reload;验证 HTTPS
`/login`
、
`/health/ready`
(JSON
`status=ok`
、
`db=up`
)及 PAC
`/health`
。ECom
生产回调登记为
`https://fai.friday.tech/hook/ecom`
,
必须等新中转就绪后再登记,保留测试
`relay.jarvismedical.asia`
的用途与配置。
## 10. 开户、组织、席位
## 10. 开户、组织、席位
...
@@ -346,7 +351,7 @@ install -m 644 deploy/production/friday-ai.logrotate /etc/logrotate.d/friday-ai-
...
@@ -346,7 +351,7 @@ install -m 644 deploy/production/friday-ai.logrotate /etc/logrotate.d/friday-ai-
**运维须配置通知,否则日志不等于告警:**
云日志采集
`/var/log/friday-ai-health.log`
,连续三次
`degraded`
或五分钟未收到记录告警;配置接收人。另用云拨测或独立探针主机每分钟请求生产 AI
`/health/ready`
,同时检查
`status=ok`
、
`db=up`
,连续三次失败告警。监控 RDS 存储/IOPS/连接、应用机内存和证书到期。
**运维须配置通知,否则日志不等于告警:**
云日志采集
`/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 探针配置;生产域名未确定前不
启用该探针。
可复用
`deploy/health-watch.sh`
在另一台服务器运行:
`WATCH_URL=https://
fai.friday.tech`
、
`WATCH_PATH=/health/ready`
、
`WATCH_REQUIRE_DB=1`
、
`WATCH_NAME=friday-ai-prod`
,私密配置通知 webhook,沿用该脚本的失败/恢复和每日心跳机制。不要覆盖现有 PAC 探针配置;DNS、证书和服务验收完成后再
启用该探针。
服务器巡检覆盖进程内故障和积压,外部拨测覆盖主机/代理不可达,两者均不依赖开发电脑。用受控验收演练验证通知实际到达,再开放业务。
服务器巡检覆盖进程内故障和积压,外部拨测覆盖主机/代理不可达,两者均不依赖开发电脑。用受控验收演练验证通知实际到达,再开放业务。
...
@@ -358,6 +363,6 @@ install -m 644 deploy/production/friday-ai.logrotate /etc/logrotate.d/friday-ai-
...
@@ -358,6 +363,6 @@ install -m 644 deploy/production/friday-ai.logrotate /etc/logrotate.d/friday-ai-
-
中转独立更新:第 8 节中转段;仍有自身重启窗口,预先核对供应商重试策略。
-
中转独立更新:第 8 节中转段;仍有自身重启窗口,预先核对供应商重试策略。
-
回退先保存部署 SHA、运行镜像 ID、配置备份、迁移记录及队列状态,由开发确认旧镜像兼容已迁移 schema,再切旧镜像;不得直接 git reset / Prisma reset 或整库恢复。
-
回退先保存部署 SHA、运行镜像 ID、配置备份、迁移记录及队列状态,由开发确认旧镜像兼容已迁移 schema,再切旧镜像;不得直接 git reset / Prisma reset 或整库恢复。
-
AI 与中转共用
`friday_ai`
库。AI 停止时中转仍在写,整库恢复会覆盖备份后已向 ECom 确认的消息;必须先保全队列及 AI 回执,再制定恢复步骤。
-
AI 与中转共用
`friday_ai`
库。AI 停止时中转仍在写,整库恢复会覆盖备份后已向 ECom 确认的消息;必须先保全队列及 AI 回执,再制定恢复步骤。
-
本次生产初始化不复用测试数据库、用户、企微设备或
密钥
;不改既有 PAC 宿主授权,不部署自动回写服务。
-
本次生产初始化不复用测试数据库、用户、企微设备或
内部服务密钥;仅 OSS 和模型 API 凭据按已确认方案共用
;不改既有 PAC 宿主授权,不部署自动回写服务。
交付记录:发布 SHA、各镜像标签/ID、RDS 备份 ID、迁移结果、域名/证书、开户/席位编号、功能开关、真实读取/隔离/消息补发验收、已知限制。未使用的步骤汇总能力已撤掉,不执行回填。凭据单独交付。
交付记录:发布 SHA、各镜像标签/ID、RDS 备份 ID、迁移结果、域名/证书、开户/席位编号、功能开关、真实读取/隔离/消息补发验收、已知限制。未使用的步骤汇总能力已撤掉,不执行回填。凭据单独交付。
docs/friday-ai-production-handoff.md
View file @
1b78c5ec
# FridayAI
首次生产上线:运维交接
单
# FridayAI
生产上线:运维协助清
单
应用机
`ssh pac-friday`
,源码
`/data/pac`
,分支
`main`
。PAC 已运行;FridayAI 和中转尚未上线。详细命令见
[
生产部署步骤
](
friday-ai-production-deployment.md
)
,执行证据见
[
准备记录
](
qa/2026-10-10-production-readiness.md
)
。
## 项目与环境
## 已准备
仓库:
<https://code.jwsmed.com/ai-tools/pac>
(PAC / FridayAI 单仓库)。
-
生产发布脚本、远端网关端口覆盖、独立中转部署和配置预检。
| 环境 | 应用服务器 | 代码目录 / 分支 | 域名 |
-
服务器健康/队列巡检、cron 与日志轮转模板;不依赖开发电脑。
| --- | --- | --- | --- |
-
生产专用 JWT 私钥、读取密钥、中转密钥、数据库应用密码及 env 草稿,位于应用机
`/data/pac-ops/readiness-20261010/config/`
,尚未激活。
| 生产(本次操作) |
`47.99.62.30:3335`
;私网
`172.16.4.239`
|
`/data/pac`
/
`main`
| 新增
`fai.friday.tech`
;现有 PAC 为
`pac.friday.tech`
|
-
PAC 三个大表索引已于 2026-10-10 01:55 建成并验证有效,合计约 2.73 GiB;5i5ya 诊所名称字典已补齐 66 条。正式发布仍需运行七条迁移登记,已建索引会跳过重复创建。
| 测试(仅供参考) |
`47.251.104.47:19822`
|
`/root/pac`
/
`test`
|
`fai.jarvismedical.asia`
;回调
`relay.jarvismedical.asia`
|
-
未使用的治疗步骤汇总功能已移除,不需要回填或全量重算患者。
## 请运维处理
生产 Nginx 在现有独立网关,
**不在应用服务器上**
。沿用
`pac.friday.tech`
的网关(现有入口
`lb1.friday.tech`
,公网
`47.98.152.240`
);请运维核对网关实例后配置。
| 项目 | 运维执行 / 提供 | 验收 |
## 1. RDS:新建库和专用账号
| --- | --- | --- |
| 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 生产租户
。
实例:
`fridaypac.pg.rds.aliyuncs.com:5432`
(现有 PAC 使用的 PostgreSQL 实例)
。
## 实施顺序
用 RDS 管理账号连接
`postgres`
库,执行以下 SQL;
`CREATE DATABASE`
不放在事务中:
1.
核对准备记录和索引状态;确认 RDS 备份、网关限制、所有生产配置已齐。
```
sql
2.
备份现有配置和镜像记录;
`main`
快进到本次经过测试的 SHA,保护现有未跟踪文件。
CREATE
ROLE
friday_ai
LOGIN
;
3.
补全 env 草稿、安装配置和私钥;PAC/AI 使用同一服务读取密钥。
CREATE
DATABASE
friday_ai
OWNER
friday_ai
TEMPLATE
template0
4.
`COMPOSE_MANAGED=1 bash deploy/deploy-prod.sh --no-pull`
升级 PAC,核验七条迁移及真实读取;不首灌、不回填、不全量重算。
ENCODING
'UTF8'
LC_COLLATE
'C'
LC_CTYPE
'en_US.utf8'
;
5.
`COMPOSE_MANAGED=1 bash deploy/deploy-ai.sh production --no-pull`
。
ALTER
DATABASE
friday_ai
SET
timezone
TO
'UTC'
;
6.
`COMPOSE_MANAGED=1 bash deploy/deploy-ingress.sh production`
;检查健康,启用网关和巡检。
```
7.
CLI 开户,配置组织/员工范围及席位;验收 5i5ya 患者数据和诊所隔离。
8.
先开 worker、后按业务确认开 scheduler;专用号验收收发/图片/文件及 AI 停服补投,再开放正式业务。
**生产发布仍有短暂停服;本次没有承诺零停机。**
中转保护 AI 停服期间已持久接收的回调,不覆盖中转、主机或数据库自身故障。首次上线不能照搬测试账号、设备或密钥。
随后
**切换连接到 `friday_ai` 库**
,执行:
```
sql
CREATE
EXTENSION
IF
NOT
EXISTS
pgcrypto
;
CREATE
EXTENSION
IF
NOT
EXISTS
btree_gist
;
```
-
用 RDS 控制台/DMS 设置
`friday_ai`
密码,或在 psql 中执行
`\password friday_ai`
;通过安全渠道交付密码。
-
账号需拥有该库及
`public`
schema 建表、索引、约束和读写权限;仅使用
`friday_ai`
,不复用
`pac`
账号。
-
确认应用机
`172.16.4.239`
可连接该实例。
-
不改现有
`pac`
库、账号及实例全局时区。若库或账号已存在,核对后补齐权限,不删除重建。
-
发布前确认 PAC 可恢复备份和恢复点、RDS 磁盘/IOPS 余量;新库纳入自动备份。
SQL 文件:
[
friday-ai-database.sql
](
../deploy/production/friday-ai-database.sql
)
(扩展语句须切换库后执行)。
## 2. 域名、证书与网关
1.
新增
`fai.friday.tech`
的 DNS,指向现有生产网关;申请 HTTPS 证书并配置自动续期。
2.
在网关新增独立站点,使用
[
friday-ai-gateway.conf.example
](
../deploy/production/friday-ai-gateway.conf.example
)
,替换两处证书路径。
3.
配置网关到应用机的网络放行:仅允许网关访问
`172.16.4.239`
的 TCP
`3103 / 3104 / 3106`
,不开放公网直连。
4.
执行
`nginx -t`
,通过后 reload;保留现有 PAC 和测试站点配置。
模板已包含以下转发规则,请完整保留:
| 路径 | 上游 |
| --- | --- |
|
`/`
|
`http://172.16.4.239:3103`
|
|
`/api/`
、
`/health`
、
`/health/ready`
|
`http://172.16.4.239:3104`
|
| 仅 POST
`/hook/ecom`
|
`http://172.16.4.239:3106`
|
模板已配置 SSE 禁用缓冲、长连接超时和上传限制,并禁止访问中转调试/管理路径。若网关前还有其他代理,请告知代理层数和真实来源 IP 的传递方式。
应用部署完成后共同验收:
`https://fai.friday.tech/login`
可打开;
`/health/ready`
返回 HTTP 200 且包含
`status=ok`
、
`db=up`
。应用未启动前上游暂不可用属预期,正式切换回调由我们协调。
## 3. 接入现有监控告警(应用上线后启用)
-
采集应用机
`/var/log/friday-ai-health.log`
:连续三条
`degraded`
,或五分钟无新日志时通知负责人。
-
外部拨测
`https://fai.friday.tech/health/ready`
:每分钟一次,连续三次失败告警,检查 HTTP 状态和上述 JSON 字段。
-
纳入主机内存/磁盘、RDS 存储/IOPS/连接数、证书到期监控,配置异常及恢复通知。
## 完成后反馈
-
新库/账号已就绪、密码已安全交付、备份恢复点。
-
DNS、证书、网关配置完成,网络规则已放行;有额外代理时说明。
-
告警接收人及通知渠道。
docs/friday-ai-test-deployment.md
View file @
1b78c5ec
...
@@ -5,6 +5,7 @@
...
@@ -5,6 +5,7 @@
-
服务器:
`ssh -p 19822 root@47.251.104.47`
。
-
服务器:
`ssh -p 19822 root@47.251.104.47`
。
-
源码:
`/root/pac`
,分支
`test`
;feature 合入 test,验证通过后再由 feature 合入 main。
-
源码:
`/root/pac`
,分支
`test`
;feature 合入 test,验证通过后再由 feature 合入 main。
-
域名:
`https://fai.jarvismedical.asia`
;Web 3103、API 3104,仅发布到主机 loopback。
-
域名:
`https://fai.jarvismedical.asia`
;Web 3103、API 3104,仅发布到主机 loopback。
-
测试发布不设置
`COMPOSE_MANAGED`
(或设为
`0`
);误设为
`1`
会在构建、迁移和重启前被拒绝。
-
使用 Docker Compose;Node、pnpm 随镜像提供,不安装主机运行时,不使用 systemd。
-
使用 Docker Compose;Node、pnpm 随镜像提供,不安装主机运行时,不使用 systemd。
-
PAC 已有
`pac_default`
网络及 PostgreSQL;AI 使用独立
`friday_ai`
库、独立
`pac-ai`
Compose 项目。
-
PAC 已有
`pac_default`
网络及 PostgreSQL;AI 使用独立
`friday_ai`
库、独立
`pac-ai`
Compose 项目。
...
...
docs/qa/2026-10-10-production-readiness.md
View file @
1b78c5ec
...
@@ -55,6 +55,6 @@
...
@@ -55,6 +55,6 @@
## 运维交付
## 运维交付
按
[
生产部署
](
../friday-ai-production-deployment.md
)
实施。需要运维执行的 SQL、网关、cron、日志轮转模板在
`deploy/production/`
。
待提供:生产域名/证书,专用数据库账号,ECom/OSS/模型生产配置
,告警接收人,首个集团/管理员/诊所/席位信息。无需交付 RDS 管理账号。
按
[
生产部署
](
../friday-ai-production-deployment.md
)
实施。需要运维执行的 SQL、网关、cron、日志轮转模板在
`deploy/production/`
。
后续已确定 AI 与回调共用
`fai.friday.tech`
,OSS 和模型凭据复用测试配置(OSS 生产前缀
`friday-ai-prod`
)。待提供/落实:该域名 DNS/证书,专用数据库账号,ECom 生产配置及共用资源权限/额度核验
,告警接收人,首个集团/管理员/诊所/席位信息。无需交付 RDS 管理账号。
生产准备工具已验证后合入 main
`ad25aed`
、test
`938dff5`
;本次收尾仅更新完成记录及运维步骤,不更改应用代码。临时索引/字典任务已退出,收尾自动化在文档同步完成后停用。
生产准备工具已验证后合入 main
`ad25aed`
、test
`938dff5`
;本次收尾仅更新完成记录及运维步骤,不更改应用代码。临时索引/字典任务已退出,收尾自动化在文档同步完成后停用。
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment