feat(交付): 客户侧两台服务器离线部署包

内网机/外网机各起一套栈,两台之间不开任何端口,文件只经光闸在
gate/out 与对端 gate/in 之间单向摆渡。一份 compose + 一份 config +
一份 nginx 模板,全部差异收在 .env 里。

- docker-compose.yml:5 服务只发布前端端口;app 容器 IP 锁死并由
  compose 注入 -Dcode.snowflake.workid.<IP>=<WORK_ID>,绕开兜底 99
  超雪花 5 位上限导致启动即崩的问题
- .env.iw / .env.ow:出厂配置与随机口令已填好,仅 MESHSAFE_SECRET
  故意留占位(以 MeshSafe 侧白名单为准);按 maintainer 决定原样入库
- install.sh iw|ow:按角色建 .env -> 自检 -> sha256 校验 tar -> 架构
  核对 -> docker load 后校验 tag -> 建目录/属主/SELinux 标签 -> 起栈
  并逐个等健康。修掉 pipefail 下 `grep | head -1` 静默中断、架构字段
  误读 manifest.json(实际在 Config blob)、die() 未定义先被调用
- export-images.sh:就地刷新 images/*.tar、sql、.env 的 tag 与
  SHA256SUMS,再打 dist 交付盘;tar 与 SHA256SUMS 由 .gitignore 排除
- sql/01+02:从 backend/db 的 PG 全量与初始数据覆盖拷贝。三个
  upgrade-*.sql 的 PG 段(x_upload_config、x_file_security)已在
  init-table-postgres-full.sql 内,空库跑 01->02 即完整现状
- smoke_test.py:18 条断言的现场验收,凭证全部走环境变量
- 部署说明.md:拓扑、交付清单、出厂口令表、iw/ow 差异、验收清单、
  13 条已知坑

镜像 tar 不进 git,装机包由 export-images.sh 出盘。
This commit is contained in:
KodouWZF
2026-11-02 20:40:16 +08:00
parent 3910239b87
commit 4eb67e246e
15 changed files with 3510 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
# =============================================================================
# s-file 客户侧离线部署 —— 唯一需要现场修改的文件
#
# 用法:cp .env.example .env 然后按本机角色填写,两台服务器各填一份
# 内网机:SFILE_ROLE=iw SFILE_TRANSFER_LOCATION=1 SFILE_SITE_ROOT=/usr/share/nginx/inside
# 外网机:SFILE_ROLE=ow SFILE_TRANSFER_LOCATION=2 SFILE_SITE_ROOT=/usr/share/nginx/outside
#
# 凡是写着「必须改」的值,不改起不来或者起来即安全隐患
# =============================================================================
# ---------- 本机角色(必须改) ----------
# iw=内网机 / ow=外网机,只影响容器名前缀与 MQ 交换机命名,两侧不能填同一个
SFILE_ROLE=iw
# 传输方向标记:内网机 1,外网机 2。写反了跨网摆渡直接不工作
SFILE_TRANSFER_LOCATION=1
# 前端站点根:内网机 inside,外网机 outside(同一个 web 镜像内含两套前端包)
SFILE_SITE_ROOT=/usr/share/nginx/inside
# ---------- 镜像(来自交付 tar,现场 docker load,不会联网拉取) ----------
APP_IMAGE=sfile-app:1.0.0
WEB_IMAGE=sfile-web:1.0.0
PG_IMAGE=postgres:18
REDIS_IMAGE=redis:8
MQ_IMAGE=rabbitmq:3.13-management-alpine
# postgres 数据目录挂载点:18 及以后是 /var/lib/postgresql,15 及以前是 /var/lib/postgresql/data
# 用交付的 PG_IMAGE 就不用动;自行换库版本时按上面规则改这一行
PG_DATA_DIR=/var/lib/postgresql
# ---------- 对外访问 ----------
# 只暴露前端一个端口;后端/数据库/Redis/MQ 全部只在容器网络内,不映射宿主机
WEB_HOST=0.0.0.0
WEB_PORT=80
# ---------- 口令与密钥(必须改) ----------
POSTGRES_DB=s_file
POSTGRES_USER=sfile
POSTGRES_PASSWORD=CHANGE_ME_pg_password
MQ_USER=sfile
MQ_PASSWORD=CHANGE_ME_mq_password
# JWT 签名密钥,至少 32 字符,两侧不要相同,丢失即所有登录态失效
JWT_SECRET=CHANGE_ME_jwt_secret_at_least_32_chars
# 新建用户的初始密码
USER_DEFAULT_PASSWORD=CHANGE_ME_user_init_pwd
# ---------- 发号器(workerId)与容器网段 ----------
# 后端雪花 ID 的 workerId 按「app 容器 IP」取键;取不到会回退 99,而 99 超过 5 位上限,
# 应用会直接启动失败(worker Id can't be greater than 31)。所以这里必须锁 IP。
# 下面三个值必须同一网段,一般不用改;只有和客户机器上已有 docker 网段冲突时才整段换掉
APP_SUBNET=172.30.88.0/24
APP_GATEWAY=172.30.88.1
APP_NODE_IP=172.30.88.10
# 发号器机器号,0-31。两台独立服务器各跑一套,取同一个值也没问题;
# 若同一台机器上并跑内网+外网两套栈,必须给不同值,否则生成的 ID 会撞
APP_WORK_ID=1
# ---------- 主机数据目录 ----------
# 应用上传文件、日志、数据库/Redis/MQ 持久化的根目录,需独立数据盘且预留足够空间
SFILE_DATA_ROOT=/data/sfile
# 光闸摆渡目录:本机的「发出去」目录,由光闸设备单向搬运到对端
GATE_SEND_DIR=/data/sfile/gate/out
# 光闸摆渡目录:对端经光闸投递进来的目录
GATE_RECV_DIR=/data/sfile/gate/in
# ---------- MeshSafe 文件安全检测 ----------
# 没有部署 MeshSafe 时置 false,前端自动隐藏「安全检测」列,跨网摆渡不受影响
MESHSAFE_ENABLED=true
# 同机部署:http://host.docker.internal:44380(需 Docker >= 20.10)
# 独立服务器/集群:http://<MeshSafe地址>:<端口>
MESHSAFE_BASE_URL=http://host.docker.internal:44380
# 必须与 MeshSafe 侧 MESHSAFE_API_CLIENTS 白名单里的凭证一致(必须改)
MESHSAFE_CLIENT_ID=CHANGE_ME_client_id
MESHSAFE_SECRET=CHANGE_ME_client_secret
# 送检来源标识,两侧区分开便于审计追溯
MESHSAFE_CHANNEL=sfile-iw
# 检测服务不可用时:true=放行并记录,false=阻断(等保从严建议 false)
MESHSAFE_FAIL_OPEN=true
MESHSAFE_TIMEOUT_SECONDS=120
MESHSAFE_POLL_TIMEOUT_MINUTES=60
# 拉取检测结论的轮询周期,默认每 10 秒
MESHSAFE_POLL_CRON=0/10 * * * * ?
# ---------- 资源与运行时 ----------
TZ=Asia/Shanghai
# 按机器内存调整,堆外还需预留 1G 左右
APP_JAVA_OPTS=-Xms1g -Xmx3g -Dfile.encoding=UTF-8
REDIS_MAXMEMORY=512mb
# 跨网文件监听任务周期,默认每分钟扫一次摆渡目录
FILE_TRANSFER_MONITOR_CRON=0 */1 * * * ?

62
deploy/offline/.env.iw Normal file
View File

@@ -0,0 +1,62 @@
# =============================================================================
# s-file 交付配置 —— 内网机(拷到内网服务器后 mv 成 .env)
#
# 装机后必须立刻改的两处:admin 登录口令、新用户初始密码 USER_DEFAULT_PASSWORD
# 下面的库/MQ 口令是出厂默认值,客户内网可用,但交付单要写清并建议现场轮换
# =============================================================================
# ---------- 本机角色 ----------
SFILE_ROLE=iw
SFILE_TRANSFER_LOCATION=1
SFILE_SITE_ROOT=/usr/share/nginx/inside
# ---------- 镜像(images/ 里的 tar docker load 后就是这些 tag) ----------
APP_IMAGE=sfile-app:1.0.0
WEB_IMAGE=sfile-web:1.0.0
PG_IMAGE=postgres:18
REDIS_IMAGE=redis:8
MQ_IMAGE=rabbitmq:3.13-management-alpine
PG_DATA_DIR=/var/lib/postgresql
# ---------- 对外访问:只暴露前端 ----------
WEB_HOST=0.0.0.0
WEB_PORT=80
# ---------- 口令与密钥 ----------
POSTGRES_DB=s_file
POSTGRES_USER=sfile
POSTGRES_PASSWORD=Sfile-iw-Pg-N1tOz1MxgdaS
MQ_USER=sfile
MQ_PASSWORD=Sfile-iw-Mq-UTqVSnCDZ0LB
JWT_SECRET=86a6b015f3292eb3fdab298d3eced941da4276d47e24e924b82ab8adee397d70
USER_DEFAULT_PASSWORD=Sfile-iw-Usr-FBLS-0fA
# ---------- 发号器与容器网段(与外网机各用一段,同机并跑时才需要错开) ----------
APP_SUBNET=172.30.88.0/24
APP_GATEWAY=172.30.88.1
APP_NODE_IP=172.30.88.10
APP_WORK_ID=1
# ---------- 主机数据目录 ----------
SFILE_DATA_ROOT=/data/sfile
GATE_SEND_DIR=/data/sfile/gate/out
GATE_RECV_DIR=/data/sfile/gate/in
# ---------- MeshSafe 文件安全检测 ----------
# 凭证必须与 MeshSafe 侧 MESHSAFE_API_CLIENTS 白名单一致,现场按 MeshSafe 交付单核对
MESHSAFE_ENABLED=true
MESHSAFE_BASE_URL=http://host.docker.internal:44380
MESHSAFE_CLIENT_ID=sfile-iw
MESHSAFE_SECRET=CHANGE_ME_meshsafe_secret
MESHSAFE_CHANNEL=sfile-iw
# 等保从严建议 false:检测服务不可用时阻断而不是放行
MESHSAFE_FAIL_OPEN=true
MESHSAFE_TIMEOUT_SECONDS=120
MESHSAFE_POLL_TIMEOUT_MINUTES=60
MESHSAFE_POLL_CRON=0/10 * * * * ?
# ---------- 资源与运行时 ----------
TZ=Asia/Shanghai
APP_JAVA_OPTS=-Xms1g -Xmx3g -Dfile.encoding=UTF-8
REDIS_MAXMEMORY=512mb
FILE_TRANSFER_MONITOR_CRON=0 */1 * * * ?

61
deploy/offline/.env.ow Normal file
View File

@@ -0,0 +1,61 @@
# =============================================================================
# s-file 交付配置 —— 外网机(拷到外网服务器后 mv 成 .env)
#
# 与内网机只差:角色/传输方向/站点根/网段/机器号/目录名/口令/JWT
# 装机后必须立刻改的两处:admin 登录口令、新用户初始密码 USER_DEFAULT_PASSWORD
# =============================================================================
# ---------- 本机角色 ----------
SFILE_ROLE=ow
SFILE_TRANSFER_LOCATION=2
SFILE_SITE_ROOT=/usr/share/nginx/outside
# ---------- 镜像(images/ 里的 tar docker load 后就是这些 tag) ----------
APP_IMAGE=sfile-app:1.0.0
WEB_IMAGE=sfile-web:1.0.0
PG_IMAGE=postgres:18
REDIS_IMAGE=redis:8
MQ_IMAGE=rabbitmq:3.13-management-alpine
PG_DATA_DIR=/var/lib/postgresql
# ---------- 对外访问:只暴露前端 ----------
WEB_HOST=0.0.0.0
WEB_PORT=80
# ---------- 口令与密钥(与内网机不同) ----------
POSTGRES_DB=s_file
POSTGRES_USER=sfile
POSTGRES_PASSWORD=Sfile-ow-Pg-pT-673--i1rk
MQ_USER=sfile
MQ_PASSWORD=Sfile-ow-Mq-sfW5Cv-Nk-cA
JWT_SECRET=9eeeea031052a156bb278607f89dac3e45282f8a934fa0ba01c24facacde6988
USER_DEFAULT_PASSWORD=Sfile-ow-Usr-WqDJ7PDS
# ---------- 发号器与容器网段 ----------
APP_SUBNET=172.30.98.0/24
APP_GATEWAY=172.30.98.1
APP_NODE_IP=172.30.98.10
APP_WORK_ID=2
# ---------- 主机数据目录 ----------
SFILE_DATA_ROOT=/data/sfile
GATE_SEND_DIR=/data/sfile/gate/out
GATE_RECV_DIR=/data/sfile/gate/in
# ---------- MeshSafe 文件安全检测 ----------
# 外网机一般也走一次检测(接收解包后送检);该侧不部署 MeshSafe 时置 MESHSAFE_ENABLED=false
MESHSAFE_ENABLED=true
MESHSAFE_BASE_URL=http://host.docker.internal:44380
MESHSAFE_CLIENT_ID=sfile-ow
MESHSAFE_SECRET=CHANGE_ME_meshsafe_secret
MESHSAFE_CHANNEL=sfile-ow
MESHSAFE_FAIL_OPEN=true
MESHSAFE_TIMEOUT_SECONDS=120
MESHSAFE_POLL_TIMEOUT_MINUTES=60
MESHSAFE_POLL_CRON=0/10 * * * * ?
# ---------- 资源与运行时 ----------
TZ=Asia/Shanghai
APP_JAVA_OPTS=-Xms1g -Xmx3g -Dfile.encoding=UTF-8
REDIS_MAXMEMORY=512mb
FILE_TRANSFER_MONITOR_CRON=0 */1 * * * ?

7
deploy/offline/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# 交付包里的镜像 tar 是出盘产物(约 650MB),不进 git
images/*.tar
images/SHA256SUMS.txt
dist/
# 现场改出来的配置不进 git(.env.iw / .env.ow 是出厂默认,随包交付)
.env

View File

@@ -0,0 +1,22 @@
s-file 离线交付包 1.0.0
构建平台: linux/amd64
导出时间: 2026-11-02 13:58:00
镜像清单(sha256 见 images/SHA256SUMS.txt):
postgres.tar 161M "RepoTags":["postgres:18"]
rabbitmq.tar 84M "RepoTags":["rabbitmq:3.13-management-alpine"]
redis.tar 53M "RepoTags":["redis:8"]
sfile-app.tar 313M "RepoTags":["sfile-app:1.0.0"]
sfile-web.tar 39M "RepoTags":["sfile-web:1.0.0"]
SQL: sql/01-init-table.sql (93974 B) sql/02-init-data.sql (2791 B)
本包所含修复: 弹窗内容被长文件名撑破右边框(DialogContent grid 隐式轨道 min-content 撑开),
传输/分享确认框的被拦截文件清单由截断改为换行完整显示。
现场步骤(两台服务器各执行一次):
内网机 tar -xzf sfile-offline-1.0.0-linux-amd64.tar.gz && cd sfile-offline-1.0.0-linux-amd64
vi .env.iw # 至少改 MESHSAFE_SECRET,按需改口令/网段/目录
./scripts/install.sh iw
外网机 同上,改成 ./scripts/install.sh ow
验证 python3 scripts/smoke_test.py

View File

@@ -0,0 +1,97 @@
# =============================================================================
# s-file 客户侧静态默认配置(两台机器共用同一份,现场一般不用改)
#
# 随角色/环境变化的取值一律由 docker-compose 的环境变量注入,本文件不放:
# 数据库/Redis/MQ 地址与口令、JWT 密钥、用户初始密码、传输方向与摆渡目录、
# MeshSafe 开关与凭证、MQ 交换机命名 —— 全部在 .env 里填
# 这样交付物里不存在任何写死的口令或密钥
# =============================================================================
server:
port: 8080
spring:
application:
name: s-file
datasource:
driver-class-name: org.postgresql.Driver
type: com.alibaba.druid.pool.DruidDataSource
druid:
initial-size: 5
min-idle: 5
max-active: 50
validation-query: SELECT 1
test-while-idle: true
# 摆渡解包/送检会在多任务里并发取连接,压太低会出现等待
max-wait: 10000
redis:
timeout: 5000
lettuce:
pool:
max-active: 16
max-idle: 8
servlet:
multipart:
max-file-size: 500MB
max-request-size: 500MB
main:
allow-bean-definition-overriding: true
mybatis-plus:
mapper-locations: classpath*:com/senmesh/**/mapper/*.xml
# 平台编码,需与 sql/02-init-data.sql 里 uc_platform.platform_code 一致
system:
code: 1
security:
jwt:
enable: true
# secret 由环境变量 SECURITY_JWT_SECRET 注入,勿在此填写
expiration: 86400000
header: Authorization
ignoring: /error,/favicon.ico
login-page: /login
superior-only: false
# 单机自闭环部署:权限走本地库,不接外部用户中心
use-local: true
allow-if-all-abstain-decisions: true
oauth2:
enable: false
not-logs: ""
anonymous: ""
# 新用户初始密码由环境变量 USER_DEFAULT_PASSWORD 注入
user:
default:
password: ${USER_DEFAULT_PASSWORD:}
file:
upload-dir: /data/upload
storage:
type: local
transfer:
# location / send-dir / receive-dir / monitor.cron 均由环境变量注入
# 解包时是否再做一次内容风险识别(依赖下面的 dify,未部署 dify 时保持 false)
unpack-risk-check: false
# 内容风险识别(Dify)。未部署 Dify 时留空并保持 file.transfer.unpack-risk-check=false
dify:
risk:
rule:
api-key: ""
scan:
api-key: ""
# MeshSafe 全段由环境变量注入(MESHSAFE_*),未部署时 MESHSAFE_ENABLED=false 即可,
# 前端会自动隐藏「安全检测」列,跨网摆渡不受影响
meshsafe:
review-sync-to-meshsafe: true
# 接口文档在生产关闭
swagger:
enable: false
logging:
level:
com.senmesh: info

View File

@@ -0,0 +1,189 @@
# =============================================================================
# s-file 信创文件安全传输系统 —— 客户侧单台服务器部署(内网机 / 外网机通用同一份)
#
# 一台服务器一套本栈;两台机器靠光闸摆渡目录互通,彼此之间不开任何网络端口。
# 所有随角色变化的取值都在同目录 .env 里,本文件现场不用改。
#
# ./scripts/install.sh iw # 内网机(第一次会把 .env.iw 拷成 .env)
# ./scripts/install.sh ow # 外网机
#
# 手动起也行:cp .env.iw .env && vi .env -> docker load -i images/*.tar -> docker compose up -d
#
# 只发布前端端口;后端 / PostgreSQL / Redis / RabbitMQ 仅在容器网络内可达。
# =============================================================================
name: sfile-${SFILE_ROLE:?缺少 SFILE_ROLE(内网机 iw / 外网机 ow)}
services:
web:
image: ${WEB_IMAGE:?缺少 WEB_IMAGE}
container_name: sfile-${SFILE_ROLE}-web
restart: unless-stopped
ports:
- "${WEB_HOST:-0.0.0.0}:${WEB_PORT:-80}:80"
environment:
TZ: ${TZ:-Asia/Shanghai}
# nginx 官方镜像的 /etc/nginx/templates/*.template 会按 NGINX_ENVSUBST_FILTER
# 过滤后 envsubst 生成 conf;限定只替换 SFILE_ 开头的变量,避免动到 $uri/$http_host
NGINX_ENVSUBST_FILTER: ^SFILE_
SFILE_SITE_ROOT: ${SFILE_SITE_ROOT:?缺少 SFILE_SITE_ROOT}
SFILE_APP_UPSTREAM: app
volumes:
- ./nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro
depends_on:
app:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -sf -o /dev/null http://127.0.0.1:80/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
networks: [sfile]
app:
image: ${APP_IMAGE:?缺少 APP_IMAGE}
container_name: sfile-${SFILE_ROLE}-app
restart: unless-stopped
stop_grace_period: 30s
environment:
TZ: ${TZ:-Asia/Shanghai}
# 发号器 workerId 按「容器 IP」取键,取不到会回退 99 而 99 > 31 直接启动失败,
# 因此这里锁死 app 的容器 IP,并把同一个键写进 JVM 参数,两处永远一致
JAVA_OPTS: "${APP_JAVA_OPTS:--Xms1g -Xmx3g -Dfile.encoding=UTF-8} -Dcode.snowflake.workid.${APP_NODE_IP:?缺少 APP_NODE_IP}=${APP_WORK_ID:?缺少 APP_WORK_ID}"
SPRING_CONFIG_ADDITIONAL_LOCATION: file:/config/application.yml
SPRING_DATASOURCE_URL: jdbc:postgresql://pg:5432/${POSTGRES_DB:?缺少 POSTGRES_DB}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:?缺少 POSTGRES_USER}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:?缺少 POSTGRES_PASSWORD}
SPRING_REDIS_HOST: redis
SPRING_REDIS_PORT: 6379
SPRING_RABBITMQ_HOST: mq
SPRING_RABBITMQ_PORT: 5672
SPRING_RABBITMQ_USERNAME: ${MQ_USER:?缺少 MQ_USER}
SPRING_RABBITMQ_PASSWORD: ${MQ_PASSWORD:?缺少 MQ_PASSWORD}
# 两侧交换机/广播 ID 必须不同,防止有人把两套栈并到一台机器时串消息
RABBITMQ_CUSTOM_TOPIC_EXCHANGE_NAME: s-file-${SFILE_ROLE}-topic
RABBITMQ_CUSTOM_DELAYED_EXCHANGE_NAME: s-file-${SFILE_ROLE}-delayed
RABBITMQ_CUSTOM_BROADCAST_ID: s-file-${SFILE_ROLE}-broadcast
SECURITY_JWT_SECRET: ${JWT_SECRET:?缺少 JWT_SECRET}
USER_DEFAULT_PASSWORD: ${USER_DEFAULT_PASSWORD:?缺少 USER_DEFAULT_PASSWORD}
# 内网机 1 / 外网机 2,写反了摆渡方向就反了
FILE_TRANSFER_LOCATION: "${SFILE_TRANSFER_LOCATION:?缺少 SFILE_TRANSFER_LOCATION}"
FILE_TRANSFER_SEND_DIR: /data/transfer/send
FILE_TRANSFER_RECEIVE_DIR: /data/transfer/receive
FILE_TRANSFER_MONITOR_CRON: "${FILE_TRANSFER_MONITOR_CRON:-0 */1 * * * ?}"
MESHSAFE_ENABLED: ${MESHSAFE_ENABLED:-true}
MESHSAFE_BASE_URL: ${MESHSAFE_BASE_URL:-}
MESHSAFE_CLIENT_ID: ${MESHSAFE_CLIENT_ID:-}
MESHSAFE_SECRET: ${MESHSAFE_SECRET:-}
MESHSAFE_CHANNEL: ${MESHSAFE_CHANNEL:-sfile-${SFILE_ROLE}}
MESHSAFE_FAIL_OPEN: ${MESHSAFE_FAIL_OPEN:-true}
MESHSAFE_TIMEOUT_SECONDS: ${MESHSAFE_TIMEOUT_SECONDS:-120}
MESHSAFE_POLL_TIMEOUT_MINUTES: ${MESHSAFE_POLL_TIMEOUT_MINUTES:-60}
MESHSAFE_POLL_CRON: ${MESHSAFE_POLL_CRON:-0/10 * * * * ?}
volumes:
- ./config/application.yml:/config/application.yml:ro
- ${SFILE_DATA_ROOT:?缺少 SFILE_DATA_ROOT}/upload:/data/upload
- ${SFILE_DATA_ROOT}/logs:/app/logs
# 摆渡目录:容器内固定路径,宿主机侧由光闸设备负责单向搬运
- ${GATE_SEND_DIR:?缺少 GATE_SEND_DIR}:/data/transfer/send
- ${GATE_RECV_DIR:?缺少 GATE_RECV_DIR}:/data/transfer/receive
# MeshSafe 与应用同机部署时靠它从容器内访问宿主机端口;
# Docker < 20.10 不支持 host-gateway,或 MeshSafe 在独立服务器时,删掉这两行即可
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
pg:
condition: service_healthy
redis:
condition: service_healthy
mq:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -s --max-time 5 -o /dev/null http://127.0.0.1:8080/ || exit 1"]
interval: 30s
timeout: 8s
retries: 5
# 首次建库要跑全量 DDL,冷启动给足时间
start_period: 180s
networks:
sfile:
ipv4_address: ${APP_NODE_IP}
pg:
image: ${PG_IMAGE:?缺少 PG_IMAGE}
container_name: sfile-${SFILE_ROLE}-pg
restart: unless-stopped
stop_grace_period: 60s
environment:
TZ: ${TZ:-Asia/Shanghai}
POSTGRES_DB: ${POSTGRES_DB:?缺少 POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER:?缺少 POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?缺少 POSTGRES_PASSWORD}
# 空库首次启动才执行 initdb.d,已有数据卷时会被忽略
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C"
volumes:
# postgres 18+ 数据目录挂载点是 /var/lib/postgresql,15- 是 .../data,见 .env 的 PG_DATA_DIR
- ${SFILE_DATA_ROOT}/pgdata:${PG_DATA_DIR:-/var/lib/postgresql}
- ./sql/01-init-table.sql:/docker-entrypoint-initdb.d/01-init-table.sql:ro
- ./sql/02-init-data.sql:/docker-entrypoint-initdb.d/02-init-data.sql:ro
healthcheck:
# 必须强制走 TCP(-h 127.0.0.1):首次 initdb 期间镜像会临时起一个只听 unix socket
# 的实例,此时用默认 socket 探测也会返回成功,导致 app 抢跑连库失败
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 60s
networks: [sfile]
redis:
image: ${REDIS_IMAGE:?缺少 REDIS_IMAGE}
container_name: sfile-${SFILE_ROLE}-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--maxmemory", "${REDIS_MAXMEMORY:-512mb}", "--maxmemory-policy", "allkeys-lru"]
environment:
TZ: ${TZ:-Asia/Shanghai}
volumes:
- ${SFILE_DATA_ROOT}/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
networks: [sfile]
mq:
image: ${MQ_IMAGE:?缺少 MQ_IMAGE}
container_name: sfile-${SFILE_ROLE}-mq
restart: unless-stopped
stop_grace_period: 30s
environment:
TZ: ${TZ:-Asia/Shanghai}
RABBITMQ_DEFAULT_USER: ${MQ_USER:?缺少 MQ_USER}
RABBITMQ_DEFAULT_PASS: ${MQ_PASSWORD:?缺少 MQ_PASSWORD}
volumes:
- ${SFILE_DATA_ROOT}/mq:/var/lib/rabbitmq
healthcheck:
test: ["CMD-SHELL", "rabbitmq-diagnostics -q ping"]
interval: 20s
timeout: 10s
retries: 10
start_period: 60s
networks: [sfile]
networks:
sfile:
driver: bridge
ipam:
config:
# 锁 app 容器 IP 是为了发号器 workerId 取键;subnet/网关/app IP 三个值必须同网段。
# 若与客户机器上已有 docker 网段冲突,整段换掉(如 172.31.88.0/24)即可
- subnet: ${APP_SUBNET:?缺少 APP_SUBNET}
gateway: ${APP_GATEWAY:?缺少 APP_GATEWAY}

View File

@@ -0,0 +1,39 @@
server {
listen 80;
server_name _;
charset utf-8;
# 内网机 = /usr/share/nginx/inside,外网机 = /usr/share/nginx/outside(见 .env)
root ${SFILE_SITE_ROOT};
index index.html;
# 与后端 multipart 上限(500MB)保持一致,改这里要同步改 application.yml
client_max_body_size 500m;
# /api/xxx -> 后端 /xxx(去掉 /api 前缀,与前端构建时的 VITE_API_URL=/api 对应)
location /api/ {
proxy_pass http://${SFILE_APP_UPSTREAM}:8080/;
proxy_http_version 1.1;
# 必须保留端口($http_host):后端 getRootDomain 对「无端口的 IP/localhost」会抛
# IllegalStateException,导致登录成功回调 500。改成 $host 就会复现这个坑
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 600s;
proxy_send_timeout 600s;
# 大文件上传不在 nginx 侧缓冲,直接流给后端
proxy_request_buffering off;
}
# SPA:hash 路由,静态资源找不到时回落 index.html
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|mjs|css|woff2?|ttf|png|jpe?g|gif|svg|ico)$ {
expires 7d;
access_log off;
}
}

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# =============================================================================
# 出交付盘:在我方构建机上执行,把 deploy/offline/ 刷新成一个可直接带走的完整交付包
#
# ./scripts/export-images.sh 1.0.0 # 默认 linux/amd64
# TARGET_PLATFORM=linux/arm64 ./scripts/export-images.sh 1.0.0 # 鲲鹏/飞腾
#
# 刷新后的 deploy/offline/(现场照它装,不再依赖本仓库):
# ├── images/*.tar 五个镜像 + SHA256SUMS.txt
# ├── sql/01-init-table.sql 建表 02-init-data.sql 初始数据
# ├── .env.iw / .env.ow 内网机 / 外网机出厂配置
# └── docker-compose.yml config/ nginx/ scripts/ VERSION.txt
# 另外产出 deploy/offline/dist/sfile-offline-<版本>-<平台>.tar.gz(拷盘/刻录用)
#
# 前置:backend/target/s-file.jar 与 frontend/ 已构建好(见仓库根 README)
# =============================================================================
set -euo pipefail
VER="${1:?用法:export-images.sh <版本号>,如 1.0.0}"
PLATFORM="${TARGET_PLATFORM:-linux/amd64}"
PLATFORM_TAG="$(echo "$PLATFORM" | tr -d '/:')"
HERE="$(cd "$(dirname "$0")/.." && pwd)" # deploy/offline
ROOT="$(cd "$HERE/../.." && pwd)" # 仓库根
OUT="$HERE/dist/sfile-offline-$VER-$PLATFORM_TAG"
APP_IMAGE="sfile-app:$VER"
WEB_IMAGE="sfile-web:$VER"
PG_IMAGE="postgres:18" # 验证过的是 18.3,同大版本即可
REDIS_IMAGE="redis:8" # 验证过的是 8.6.3
MQ_IMAGE="rabbitmq:3.13-management-alpine" # 验证过的是 3.13.7
say() { printf '\033[1;36m==> %s\033[0m\n' "$1"; }
say "构建目标平台:$PLATFORM"
[ "$PLATFORM" = "linux/amd64" ] || say "非 amd64 需要 buildx/QEMU 模拟器,慢属正常"
# ---------- 1. 构建业务镜像 ----------
say "构建 $APP_IMAGE"
docker build --platform "$PLATFORM" -f "$ROOT/deploy/docker/Dockerfile.app" -t "$APP_IMAGE" "$ROOT/backend/target"
say "构建 $WEB_IMAGE(内含内网+外网两套前端,耗时较长)"
docker build --platform "$PLATFORM" -f "$ROOT/deploy/docker/Dockerfile.web" -t "$WEB_IMAGE" "$ROOT/frontend"
# ---------- 2. 基础镜像就位(本机没有才拉,避免交付时缺包) ----------
for img in "$PG_IMAGE" "$REDIS_IMAGE" "$MQ_IMAGE"; do
docker image inspect "$img" >/dev/null 2>&1 || {
say "本机没有 $img,拉取 $PLATFORM 版本"
docker pull --platform "$PLATFORM" "$img"
}
done
# ---------- 3. 镜像 tar 就地导出 ----------
say "docker save 导出镜像到 $HERE/images/(约 700M,耐心等)"
mkdir -p "$HERE/images"
rm -f "$HERE"/images/*.tar "$HERE/images/SHA256SUMS.txt"
docker save -o "$HERE/images/sfile-app.tar" "$APP_IMAGE"
docker save -o "$HERE/images/sfile-web.tar" "$WEB_IMAGE"
docker save -o "$HERE/images/postgres.tar" "$PG_IMAGE"
docker save -o "$HERE/images/redis.tar" "$REDIS_IMAGE"
docker save -o "$HERE/images/rabbitmq.tar" "$MQ_IMAGE"
( cd "$HERE/images" && sha256sum *.tar > SHA256SUMS.txt )
# ---------- 4. SQL 与 .env 就地刷新 ----------
# 建表脚本必须先于灌数据脚本执行,靠文件名前缀定序
say "刷新 sql/(来自 backend/db/)"
mkdir -p "$HERE/sql"
cp "$ROOT/backend/db/init-table-postgres-full.sql" "$HERE/sql/01-init-table.sql"
cp "$ROOT/backend/db/init-data.sql" "$HERE/sql/02-init-data.sql"
say "刷新 .env.iw / .env.ow 的镜像 tag 为 $VER"
for f in .env.iw .env.ow; do
[ -f "$HERE/$f" ] || { say "缺少 $HERE/$f,跳过"; continue; }
sed -i -E "s/^APP_IMAGE=.*/APP_IMAGE=sfile-app:$VER/; s/^WEB_IMAGE=.*/WEB_IMAGE=sfile-web:$VER/" "$HERE/$f"
done
# ---------- 5. 组交付目录并打包 ----------
say "组装 $OUT"
rm -rf "$OUT"
mkdir -p "$OUT/sql"
cp "$HERE/docker-compose.yml" "$HERE/.env.example" "$HERE/.env.iw" "$HERE/.env.ow" "$OUT/"
cp -r "$HERE/config" "$HERE/nginx" "$HERE/scripts" "$HERE/sql" "$HERE/images" "$OUT/"
cp "$HERE/.gitignore" "$OUT/" 2>/dev/null || true
{
echo "s-file 离线交付包 $VER"
echo "构建平台: $PLATFORM"
echo "导出时间: $(date '+%F %T')"
echo
echo "镜像清单(sha256 见 images/SHA256SUMS.txt):"
for t in "$OUT"/images/*.tar; do
TAGS="$(tar -xOf "$t" manifest.json | tr -d '\n' | { grep -oE '"RepoTags":\[[^]]*\]' || true; } | awk 'NR==1{print}')"
printf ' %-18s %-8s %s\n' "$(basename "$t")" "$(du -h "$t" | cut -f1)" "${TAGS:-?}"
done
echo
echo "SQL: sql/01-init-table.sql sql/02-init-data.sql"
echo
echo "现场步骤(两台服务器各执行一次):"
echo " 内网机 tar -xzf sfile-offline-$VER-$PLATFORM_TAG.tar.gz && cd sfile-offline-$VER-$PLATFORM_TAG"
echo " vi .env.iw # 至少改 MESHSAFE_SECRET,按需改口令/网段/目录"
echo " ./scripts/install.sh iw"
echo " 外网机 同上,改成 ./scripts/install.sh ow"
echo " 验证 python3 scripts/smoke_test.py"
} > "$OUT/VERSION.txt"
say "打包交付盘"
mkdir -p "$HERE/dist"
tar -czf "$OUT.tar.gz" -C "$(dirname "$OUT")" "$(basename "$OUT")"
du -h "$OUT.tar.gz" | awk '{print " 交付盘: "$1" "$2}'
echo " 两台服务器各自解压同一份盘,装完只差 install.sh 的参数(iw / ow)"

View File

@@ -0,0 +1,205 @@
#!/usr/bin/env bash
# =============================================================================
# s-file 客户侧一键安装(在每台服务器上各执行一次)
#
# cd /opt/sfile && ./scripts/install.sh iw # 内网机
# cd /opt/sfile && ./scripts/install.sh ow # 外网机
#
# 首次执行会按角色把交付包里的 .env.iw / .env.ow 拷成 .env;之后改 .env 即可,
# 想回到交付默认值就删掉 .env 重跑。
#
# 做五件事:按角色选配置 -> 环境自检 -> 载入镜像 tar -> 建数据目录 -> 起栈并等到健康
# 幂等:重复执行只重建有差异的容器,不动已初始化好的数据库
# =============================================================================
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
cd "$HERE"
ROLE_ARG="${1:-}"
envget() { grep -E "^[[:space:]]*$1=" .env | tail -1 | cut -d= -f2- | tr -d '"' | sed 's/[[:space:]]*$//'; }
step() { printf '\n\033[1;36m==> %s\033[0m\n' "$1"; }
die() { printf '\033[1;31m!!! %s\033[0m\n' "$1" >&2; exit 1; }
case "$ROLE_ARG" in
""|iw|ow) ;;
*) die "未知角色「$ROLE_ARG」,只支持 iw(内网机)或 ow(外网机)" ;;
esac
# ---------- 0. 配置就位 ----------
if [ ! -f .env ]; then
case "$ROLE_ARG" in
iw|ow)
[ -f ".env.$ROLE_ARG" ] || die "缺少 .env.$ROLE_ARG(交付包不完整)"
step "按角色初始化配置:cp .env.$ROLE_ARG .env"
cp ".env.$ROLE_ARG" .env
;;
"") die "用法:./scripts/install.sh iw|ow(iw=内网机,ow=外网机)" ;;
esac
elif [ -n "$ROLE_ARG" ]; then
# 已有 .env 时不让角色参数悄悄覆盖现场改动
CUR_ROLE="$(envget SFILE_ROLE)"
[ "$CUR_ROLE" = "$ROLE_ARG" ] \
|| die ".env 已存在且角色是 $CUR_ROLE,与参数 $ROLE_ARG 不符;确认要换角色请先 rm .env"
fi
# ---------- 0. 前置自检 ----------
step "检查 docker 与 compose"
command -v docker >/dev/null 2>&1 || die "未安装 docker,请先装 Docker Engine(信创系统用厂商源,版本号 >= 20.10)"
docker info >/dev/null 2>&1 || die "docker 守护进程不可用,检查服务是否启动、当前用户是否在 docker 组"
if docker compose version >/dev/null 2>&1; then
DC="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
DC="docker-compose"
else
die "缺少 compose:安装 docker compose v2 插件,或用 docker-compose 命令"
fi
echo " 使用:$DC"
DOCKER_VER="$(docker version --format '{{.Server.Version}}')"
echo " docker server:$DOCKER_VER"
[ -f .env ] || die "缺少 .env —— 先执行:cp .env.example .env && vi .env"
if grep -q "CHANGE_ME" .env; then
# MeshSafe 的 secret 只有真的启用检测时才必须填,否则客户没接 MeshSafe 也装不了机
if [ "$(envget MESHSAFE_ENABLED)" = "true" ] && grep -qE "^MESHSAFE_SECRET=.*CHANGE_ME" .env; then
die "启用了 MeshSafe 但 MESHSAFE_SECRET 还是占位值:按 MeshSafe 交付单改成白名单里的 secret,或把 MESHSAFE_ENABLED 改成 false"
fi
rest="$(grep -n CHANGE_ME .env | grep -v '^.*MESHSAFE_SECRET=' | cut -d: -f2 | tr '\n' ' ')"
[ -z "$rest" ] || die ".env 里还有 CHANGE_ME 未替换:$rest"
fi
step "检查交付物"
[ -d images ] || die "缺少 images/ 目录(镜像 tar 应与本脚本同级目录的 images/ 下)"
tar_count=$(find images -maxdepth 1 -name '*.tar' | wc -l)
[ "$tar_count" -eq 5 ] || die "images/ 下应有 5 个 .tar(app/web/pg/redis/mq),实际 $tar_count 个:$(ls images)"
for f in sql/01-init-table.sql sql/02-init-data.sql; do
[ -s "$f" ] || die "缺少 $f,参见 sql/README.md"
done
echo " 镜像 tar:5 个,SQL 脚本就位"
# ---------- 1. 校验交付盘 + 镜像架构 ----------
# 650M 的 tar 经 U 盘/内网 scp 拷坏是最常见的现场故障,坏包 load 到一半才报错更难查
if [ -f images/SHA256SUMS.txt ] && [ "${SKIP_CHECKSUM:-0}" != "1" ]; then
step "校验镜像 tar 完整性(sha256,约半分钟)"
( cd images && sha256sum -c SHA256SUMS.txt ) \
|| die "有 tar 与 SHA256SUMS.txt 不一致:交付盘拷坏了,重新拷一份;确认无碍可 SKIP_CHECKSUM=1 跳过"
fi
step "核对镜像架构"
HOST_ARCH="$(uname -m)"
case "$HOST_ARCH" in
x86_64) EXPECT="amd64" ;;
aarch64|arm64) EXPECT="arm64" ;;
*) EXPECT="unknown" ;;
esac
echo " 本机:$HOST_ARCH (期望镜像架构 ${EXPECT})"
# 架构在镜像的 config blob 里,不在 manifest.json —— manifest.json 只给 Config 的路径
tar_arch() {
local t="$1" cfg
cfg="$(tar -xOf "$t" manifest.json 2>/dev/null | tr -d '\n' \
| { grep -oE '"Config":"[^"]+"' || true; } | awk -F'"' 'NR==1{print $4}')"
[ -n "$cfg" ] || { echo ""; return 0; }
tar -xOf "$t" "$cfg" 2>/dev/null | tr -d '\n' \
| { grep -oE '"architecture":"[a-z0-9-]+"' || true; } | awk -F'"' 'NR==1{print $4}'
}
if [ "$EXPECT" != "unknown" ]; then
# app 和 pg 各查一个:一个是自己构建的,一个是基础镜像,架构错了必是出盘时选错平台
for t in images/sfile-app.tar images/postgres.tar; do
[ -f "$t" ] || continue
# 不能用 `| head -1`:pipefail 下 head 提前关管道会让上游 grep 收到 SIGPIPE(141),脚本静默中断
IMG_ARCH="$(tar_arch "$t")"
if [ -n "${IMG_ARCH:-}" ] && [ "$IMG_ARCH" != "$EXPECT" ]; then
die "$(basename "$t") 是 $IMG_ARCH,本机是 $EXPECT —— 别硬装,回来重出对应架构的包"
fi
printf ' %-18s %s\n' "$(basename "$t")" "${IMG_ARCH:-未识别,继续}"
done
fi
# ---------- 2. 载入镜像 ----------
step "docker load 载入离线镜像"
for t in images/*.tar; do
echo " <- $t"
docker load -i "$t"
done
# compose 用的是 .env 里的 tag,缺一个就会去联网拉,离线机上直接卡死
for img in "$(envget APP_IMAGE)" "$(envget WEB_IMAGE)" "$(envget PG_IMAGE)" \
"$(envget REDIS_IMAGE)" "$(envget MQ_IMAGE)"; do
docker image inspect "$img" >/dev/null 2>&1 \
|| die "载入后仍看不到镜像 $img —— tar 与 .env 的 tag 不一致,别继续,回来看 images/ 和 .env"
echo " ✓ $img"
done
# ---------- 3. 目录 ----------
step "创建数据与摆渡目录"
DATA_ROOT="$(envget SFILE_DATA_ROOT)"
GATE_SEND="$(envget GATE_SEND_DIR)"
GATE_RECV="$(envget GATE_RECV_DIR)"
[ -n "$DATA_ROOT" ] || die ".env 里 SFILE_DATA_ROOT 为空"
mkdir -p "$DATA_ROOT"/{upload,logs,pgdata,redis,mq}
mkdir -p "$GATE_SEND" "$GATE_RECV"
# 数据盘挂载点没起来时直接建目录会落到根分区,先确认一下
if ! mountpoint -q "$DATA_ROOT" 2>/dev/null; then
printf ' \033[1;33m提示:%s 不是独立挂载点,确认容量够(上传+库+日志)\033[0m\n' "$DATA_ROOT"
fi
# pgdata 提前给到容器内 postgres 的 uid=999,避免镜像入口脚本 chown 失败;
# redis/mq 的官方入口脚本自己会改属主,这里只保证目录存在且可写
chown -R 999:999 "$DATA_ROOT/pgdata" 2>/dev/null || true
chmod -R 750 "$DATA_ROOT"
# SELinux(麒麟/统信多为 enforcing):无 :z 标记的绑定挂载需要放开标签,否则库起不来
if command -v chcon >/dev/null 2>&1; then
chcon -Rt svirt_sandbox_file_t "$DATA_ROOT" "$GATE_SEND" "$GATE_RECV" 2>/dev/null \
|| echo " chcon 失败(未启用 SELinux 可忽略)"
fi
echo " $DATA_ROOT / $GATE_SEND / $GATE_RECV 就位"
# ---------- 4. 起栈 ----------
step "启动服务(首次建库约 1-3 分钟)"
$DC up -d
step "等待健康检查"
ROLE="$(envget SFILE_ROLE)"
for svc in pg redis mq app web; do
name="sfile-${ROLE}-${svc}"
printf ' %-24s ' "$name"
for _ in $(seq 1 60); do
st="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$name" 2>/dev/null || echo missing)"
case "$st" in
healthy|running) printf '\033[32m%s\033[0m\n' "$st"; break ;;
missing) sleep 2 ;;
*) sleep 5 ;;
esac
done
[ "${st:-}" = "healthy" ] || [ "${st:-}" = "running" ] || {
printf '\033[31m超时(最后状态:%s)\033[0m\n' "${st:-unknown}"
$DC ps
echo " 看日志:docker logs --tail 200 ${name}"
exit 1
}
done
# ---------- 5. 出口信息 ----------
IP="$(hostname -I 2>/dev/null | tr ' ' '\n' | { grep -E '^[0-9]+\.' || true; } | awk 'NR==1{print}')"
PORT="$(envget WEB_PORT)"
step "完成"
cat <<EOF
访问地址: http://${IP:-<本机IP>}:${PORT:-80}
角色: ${ROLE}(传输方向 location=$(envget SFILE_TRANSFER_LOCATION),站点 $(envget SFILE_SITE_ROOT))
摆渡: 发出 $(envget GATE_SEND_DIR) / 收到 $(envget GATE_RECV_DIR)
MeshSafe: $(envget MESHSAFE_ENABLED) -> $(envget MESHSAFE_BASE_URL)
常用命令:
$DC ps 看状态
docker logs -f sfile-${ROLE}-app 看后端日志
$DC restart app 改完 .env/config 后重启后端
EOF
echo " 首次登录后立刻改掉 admin 的默认口令。"

View File

@@ -0,0 +1,270 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""s-file 交付验收冒烟测试:登录 -> 上传送检 -> 不通过拦截 -> 跨网摆渡 -> 外网解包再送检 -> 人工复核。
在能同时访问内网机与外网机前端的机器上运行(需要 python3 + requests)。
全部取值走环境变量,脚本内不含任何口令:
SFILE_IW_BASE=http://10.0.0.11/api SFILE_OW_BASE=http://10.0.0.12/api \\
SFILE_ADMIN_USER=admin SFILE_ADMIN_PWD='<现场口令>' python3 smoke_test.py
退出码:0 全通过,1 有失败项,2 参数缺失。
"""
import json
import os
import sys
import time
import uuid
try:
import requests
except ImportError:
print("缺少 requests:pip3 install requests")
sys.exit(2)
IW = os.environ.get("SFILE_IW_BASE", "http://127.0.0.1:28180/api").rstrip("/")
OW = os.environ.get("SFILE_OW_BASE", "http://127.0.0.1:28280/api").rstrip("/")
USER = os.environ.get("SFILE_ADMIN_USER", "admin")
PWD = os.environ.get("SFILE_ADMIN_PWD", "")
# 光闸发出目录(内网机侧本地路径)。跨机验收时留空,只断言接口成功
GATE_IW2OW = os.environ.get("SFILE_GATE_SEND_DIR", "")
# EICAR 标准测试串;后端的 MeshSafe 结构引擎会识别「内嵌 EICAR」,
# 因此允许在尾部追加唯一标记(见 main 里的载荷说明)
EICAR = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"
STATUS = {"1": "检测中", "2": "检测通过", "3": "检测不通过", "4": "检测失败"}
results = []
def rec(name, ok, detail=""):
results.append((name, bool(ok), detail))
print((" [OK] " if ok else " [FAIL] ") + name + (" " + detail if detail else ""), flush=True)
class Side:
def __init__(self, base, label):
self.base = base
self.label = label
self.s = requests.Session()
self.token = None
def _hdr(self, csrf=False):
# 后端 MyLoginSuccessHandler 靠 x-requested-with 决定是否回 JSON,否则 302 跳转
h = {"x-requested-with": "XMLHttpRequest"}
if self.token:
h["Authorization"] = "Bearer " + self.token
if csrf:
t = self.s.cookies.get("XSRF-TOKEN")
if t:
h["X-XSRF-TOKEN"] = t
return h
def login(self, user, pwd):
r = self.s.post(self.base + "/login",
data={"username": user, "password": pwd, "userType": 1},
headers=self._hdr(), timeout=30)
j = self._json(r)
if str(j.get("code")) != "200":
raise RuntimeError("登录失败 HTTP %s:%s" % (r.status_code, json.dumps(j, ensure_ascii=False)[:300]))
self.token = j["data"]["token"]
# 触发 XSRF-TOKEN cookie(POST/PUT 需要带 X-XSRF-TOKEN)
self.s.get(self.base + "/user/info", headers=self._hdr(), timeout=30)
return j
def get(self, path, params=None):
r = self.s.get(self.base + path, params=params, headers=self._hdr(), timeout=60)
return r.status_code, self._json(r)
def post(self, path, payload=None, files=None):
kw = {"files": files} if files is not None else {"json": payload if payload is not None else {}}
r = self.s.post(self.base + path, headers=self._hdr(csrf=True), timeout=180, **kw)
return r.status_code, self._json(r)
@staticmethod
def _json(r):
try:
return r.json()
except Exception:
return {"_status": r.status_code, "_raw": r.text[:400]}
def ok(j):
return str(j.get("code")) == "200"
def msg(j):
return j.get("msg") or j.get("_raw") or json.dumps(j, ensure_ascii=False)[:200]
def page_find(side, name, root_type, parent_id=0):
"""按文件名在指定列表里找记录。root_type:1 我的文件 / 2 内穿外 / 3 外传内"""
_, j = side.get("/user_file/page",
{"parentId": parent_id, "pageNo": 1, "pageSize": 50, "rootType": root_type})
if not ok(j):
return None
for e in (j.get("data") or {}).get("entityList", []):
if e.get("fileName") == name:
return e
return None
def wait_scan(side, file_code, timeout=240):
"""轮询安全检测结论;检测中(1)继续等,其余状态返回"""
deadline = time.time() + timeout
detail = None
while time.time() < deadline:
_, j = side.get("/file_security/detail", {"fileCode": file_code})
if ok(j):
detail = j.get("data")
st = str((detail or {}).get("scanStatus") or "")
if st and st != "1":
return detail
time.sleep(3)
return detail
def main():
if not PWD:
print("必须设置 SFILE_ADMIN_PWD")
return 2
tag = uuid.uuid4().hex[:8]
clean_name = "sfile-smoke-clean-%s.txt" % tag
bad_name = "sfile-smoke-eicar-%s.txt" % tag
iw, ow = Side(IW, "内网"), Side(OW, "外网")
print("\n目标:内网 %s / 外网 %s" % (IW, OW))
print("\n== 1. 登录两侧 ==")
for side in (iw, ow):
try:
side.login(USER, PWD)
rec("%s 登录" % side.label, True, "token %d 字符" % len(side.token))
except Exception as e:
rec("%s 登录" % side.label, False, str(e))
return 1
_, j = iw.get("/file_security/enabled")
ms_on = ok(j) and j.get("data") is True
rec("MeshSafe 开关", True, "enabled=%s(false 时跳过 3/4/7 段)" % ms_on)
print("\n== 2. 上传 + 建档 ==")
# fileCode 按内容寻址:同一份字节会复用上一条检测结论(含人工复核),
# 载荷必须每轮唯一,否则「不通过拦截」这类断言会被上一次的放行结论污染
uploaded = {}
for name, content in ((clean_name, ("s-file smoke clean payload %s\n" % tag) * 80),
(bad_name, EICAR + "\n# smoke-tag %s\n" % tag)):
_, j = iw.post("/file/upload", files={"file": (name, content.encode(), "text/plain")})
if not ok(j):
rec("上传 %s" % name, False, msg(j))
return 1
f = j["data"]
_, j2 = iw.post("/normal_user_file/create",
{"fileCode": f.get("fileCode"), "fileName": name, "fileType": "1", "parentId": 0})
rec("上传并建档 %s" % name, ok(j2), msg(j2) if not ok(j2) else "fileCode=%s" % f.get("fileCode"))
if not ok(j2):
return 1
uploaded[name] = f.get("fileCode")
print("\n== 3. 检测结论 ==")
ids = {}
verdicts = {}
for name, code in uploaded.items():
found = page_find(iw, name, "1")
if found:
ids[name] = found["id"]
d = wait_scan(iw, code)
st = str((d or {}).get("scanStatus") or "")
verdicts[name] = st
print(" %s -> %s | action=%s reason=%s"
% (name, STATUS.get(st, "未知"), (d or {}).get("action"), ((d or {}).get("reason") or "")[:80]))
if ms_on:
rec("干净文件检测通过", verdicts.get(clean_name) == "2", "状态=%s" % STATUS.get(verdicts.get(clean_name)))
rec("EICAR 检测不通过", verdicts.get(bad_name) == "3", "状态=%s" % STATUS.get(verdicts.get(bad_name)))
print("\n== 4. 不通过文件禁止传输 ==")
if len(ids) != len(uploaded):
rec("内网「我的文件」可见上传文件", False, "只找到 %s" % list(ids))
return 1
if ms_on:
_, j = iw.get("/file_security/share_check", {"userFileId": ids[bad_name]})
d = j.get("data") or {}
rec("share_check 命中不通过", ok(j) and d.get("blockedCount", 0) >= 1,
"blocked=%s scanning=%s" % (d.get("blockedCount"), d.get("scanningCount")))
_, j = iw.post("/transfer_user_file/create", {"userFileId": ids[bad_name]})
rec("不通过文件传输被拦截", not ok(j), msg(j)[:160])
print("\n== 5. 干净文件跨网摆渡 ==")
before = set(os.listdir(GATE_IW2OW)) if GATE_IW2OW and os.path.isdir(GATE_IW2OW) else None
_, j = iw.post("/transfer_user_file/create", {"userFileId": ids[clean_name]})
rec("内网发起传输", ok(j), msg(j) if not ok(j) else "外发文件夹ID=%s" % j.get("data"))
if not ok(j):
return 1
if before is None:
print(" 未设 SFILE_GATE_SEND_DIR(跨机验收常见),跳过摆渡包落盘检查")
else:
new_zip = None
for _ in range(30):
delta = set(os.listdir(GATE_IW2OW)) - before
if delta:
new_zip = sorted(delta)[0]
break
time.sleep(2)
rec("光闸发出目录出现摆渡包", new_zip is not None, new_zip or GATE_IW2OW)
print("\n== 6. 外网解包 + 再送检(监听任务默认每分钟一次) ==")
# 接收侧落在「内穿外」列表(root_type=2),目录名带时间戳后缀,需进目录取子文件
folder = None
deadline = time.time() + 300
while time.time() < deadline and folder is None:
_, j = ow.get("/user_file/page", {"parentId": 0, "pageNo": 1, "pageSize": 50, "rootType": "2"})
for e in (j.get("data") or {}).get("entityList", []):
if (e.get("fileName") or "").startswith(clean_name.rsplit(".", 1)[0]):
folder = e
break
if folder is None:
time.sleep(10)
rec("外网「内穿外」出现接收目录", folder is not None,
str(folder.get("fileName")) if folder else "超时未出现(检查光闸与 FILE_TRANSFER_LOCATION)")
if folder:
child = None
_, j = ow.get("/user_file/page",
{"parentId": folder["id"], "pageNo": 1, "pageSize": 50, "rootType": "2"})
for e in (j.get("data") or {}).get("entityList", []):
if e.get("fileName") == clean_name:
child = e
break
rec("接收目录内可见文件", child is not None, str(child.get("fileCode")) if child else "目录为空")
if child:
d = wait_scan(ow, child["fileCode"], timeout=180)
st = str((d or {}).get("scanStatus") or "")
rec("外网解包后自动送检", (not ms_on) or st == "2",
"状态=%s reason=%s" % (STATUS.get(st, "未知"), ((d or {}).get("reason") or "")[:60]))
print("\n== 7. 人工复核改判 ==")
if not ms_on:
print(" MeshSafe 未启用,跳过")
else:
_, j = iw.post("/file_security/review",
{"fileCode": uploaded[bad_name], "decision": "pass",
"comment": "交付验收复核放行 %s" % tag})
rec("复核改判接口", ok(j), msg(j) if not ok(j) else "")
d = j.get("data") or {}
rec("复核后状态=通过", str(d.get("scanStatus")) == "2",
"状态=%s action=%s" % (STATUS.get(str(d.get("scanStatus"))), d.get("action")))
_, j = iw.get("/file_security/detail", {"fileCode": uploaded[bad_name]})
rec("复核说明落库", ok(j) and tag in ((j.get("data") or {}).get("reviewComment") or ""),
str((j.get("data") or {}).get("reviewComment"))[:80])
_, j = iw.get("/file_security/share_check", {"userFileId": ids[bad_name]})
d = j.get("data") or {}
rec("复核后不再拦截", ok(j) and d.get("blockedCount", 0) == 0, "blocked=%s" % d.get("blockedCount"))
print("\n== 汇总 ==")
passed = sum(1 for _, o, _ in results if o)
for name, o, _ in results:
print(" %s %s" % ("OK " if o else "FAIL", name))
print(" %d/%d 通过" % (passed, len(results)))
return 0 if passed == len(results) else 1
if __name__ == "__main__":
sys.exit(main())

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
-- 系统uc_platform
INSERT INTO uc_platform
(platform_code, name, full_name, short_name, business, deleted, create_time)
VALUES(1, '文档系统', 's-file', 'file', true, FALSE, now());
-- 平台机构/平台机构管理员
INSERT INTO xs_org
(org_name, org_code, type, address_code, address_name, description, quality, enable, activated, parent_id, credit_code, trade, deleted, create_by, create_time, update_by, update_time, proxy_by, activation_code, phone, query_children, org_logo, registered_address, company_size, business_license, recruit_position_type)
VALUES('senmesh', 'O001', '1', '440232', NULL, NULL, false, TRUE, TRUE, NULL, '', NULL, FALSE, 0, now(), 1, now(), null, NULL, null, NULL, null, '1234', '6', null, '1');
INSERT INTO uc_user
(username, password, type, supeiror, security_code, nickname, avatar, phone, phone_binding, real_name, card_no, authenticated, status, email, address, education, init_user_id, enabled, account_non_expired, account_non_locked, credentials_non_expired, deleted, create_by, create_time, update_by, update_time, proxy_by, gender, birthday)
VALUES('admin', '$2a$10$0N3iaxx2sYv0hXvYzS4.6.yriL.DfjRTjED7Y4vkqxzbxi2dNpBou', 1, TRUE, '$2a$10$mGA1jdBzq8micEr02KiJ9OiYQz1fc/PSVG0hN0EYUuxhZp8EI1M2q', '平台管理员', '', '13888888888', TRUE, '平台管理员', NULL, FALSE, 2, null, NULL, NULL, NULL, TRUE, TRUE, TRUE, TRUE, FALSE, 0, now(), 1, NULL, NULL, NULL, NULL);
INSERT INTO uc_org_user
(user_id, org_id, administrator, deleted, create_by, create_time, update_by, update_time, proxy_by)
VALUES(1, 1, TRUE, FALSE, 0, now(), NULL, NULL, NULL);
-- 平台uc_org_relation
INSERT INTO uc_org_relation
(org_id, org_type, parent_id, path, platform_code, platform_name, deleted, create_by, create_time, update_by, update_time, proxy_by)
VALUES(1, 1, 0, '1', 1, NULL, FALSE, NULL, NULL, NULL, NULL, NULL);
-- ----------------------------
-- Records of x_file_category
-- ----------------------------
INSERT INTO x_file_category (file_category, file_suffix, deleted, create_by, create_time) VALUES
('video', 'mp4', FALSE, 0, NOW()),
('video', 'mkv', FALSE, 0, NOW()),
('video', 'avi', FALSE, 0, NOW()),
('video', 'mov', FALSE, 0, NOW()),
('video', 'wmv', FALSE, 0, NOW()),
('video', 'flv', FALSE, 0, NOW()),
('audio', 'mp3', FALSE, 0, NOW()),
('audio', 'wav', FALSE, 0, NOW()),
('audio', 'flac', FALSE, 0, NOW()),
('audio', 'aac', FALSE, 0, NOW()),
('document', 'txt', FALSE, 0, NOW()),
('document', 'docx', FALSE, 0, NOW()),
('document', 'pdf', FALSE, 0, NOW()),
('document', 'epub', FALSE, 0, NOW()),
('other', 'zip', FALSE, 0, NOW()),
('other', 'rar', FALSE, 0, NOW()),
('other', '7z', FALSE, 0, NOW()),
('other', 'jpg', FALSE, 0, NOW()),
('other', 'png', FALSE, 0, NOW()),
('other', 'gif', FALSE, 0, NOW()),
('other', 'tiff', FALSE, 0, NOW());

View File

@@ -0,0 +1,12 @@
建表与初始数据脚本已随包放好,文件名前缀决定执行顺序(先建表后灌数据):
| 文件 | 来源 | 说明 |
|---|---|---|
| `01-init-table.sql` | `backend/db/init-table-postgres-full.sql` | 全量 DDL,含 `x_file_security` 安全检测表 |
| `02-init-data.sql` | `backend/db/init-data.sql` | admin 账号、机构、平台编码(`system.code=1` 要与此一致) |
出盘时用 `scripts/export-images.sh` 从仓库重新覆盖这两份,别手改副本;
增量升级脚本(`backend/db/upgrade-*.sql`)不在这里,装好后按《部署说明.md》第 7 节人工执行。
只有 `POSTGRES_*` 指向的库为空时,PostgreSQL 官方镜像才会执行 `docker-entrypoint-initdb.d`;
数据目录已有数据时会跳过 —— 想重建就把 `$SFILE_DATA_ROOT/pgdata` 一起清掉再 `up`。

View File

@@ -0,0 +1,219 @@
# s-file 客户侧离线部署(两台服务器:内网机 + 外网机)
> 本目录是**交付用**部署;本机联调用的两套栈同起在一台机器上,见 [../部署说明.md](../部署说明.md)。
> 客户现场没有网络、没有光闸模拟目录,一切靠 `docker load` 的镜像 tar + 真实光闸摆渡目录。
---
## 1. 拓扑
```
内网(客户办公网) 外网(外部报送网)
┌────────────────────────┐ ┌────────────────────────┐
│ 内网服务器 A │ │ 外网服务器 B │
│ ┌──────────────────┐ │ 光闸/单向 │ ┌──────────────────┐ │
│ │ sfile-iw-web :80 │◄─┼── 摆渡设备 ──►┼─ │ sfile-ow-web :80 │ │
│ │ sfile-iw-app :8080│ │ (不开放网络) │ │ sfile-ow-app │ │
│ │ pg/redis/mq(内部) │ │ │ │ pg/redis/mq(内部)│
│ └──────────────────┘ │ │ └──────────────────┘ │
│ /data/sfile/gate/out │─── 发 ────► │ /data/sfile/gate/in │
│ /data/sfile/gate/in │◄─── 收 ──── │ /data/sfile/gate/out │
└───────────┬────────────┘ └───────────┬────────────┘
│ http://host.docker.internal:<MeshSafe端口>
┌──────┴───────────┐
│ MeshSafe 文件安全检测 │ 同机容器 / 独立服务器 / 集群均可,只要求 内网机能 HTTP 访问到
└──────────────────┘
```
- 两台服务器之间**不开任何 TCP 端口**,文件只经光闸在 `gate/out` ⇄ 对端 `gate/in` 之间单向搬运。
- 每台机器一个独立 compose 栈,同一份 `docker-compose.yml`,差别全在 `.env`。
- 摆渡目录由光闸设备挂载/同步进宿主机,应用只认容器内固定路径 `/data/transfer/send`、`/data/transfer/receive`。
---
## 2. 交付包内容
本目录本身**就是**交付包,`.env`、`sql`、`images/*.tar` 都已就位,整目录拷走即可装机(不需要现场再回仓库取任何东西)。
```
deploy/offline/
├── docker-compose.yml 一台机器的 5 个服务(web/app/pg/redis/mq),现场不改
├── .env.iw 内网机出厂配置(已填好,含口令)← install.sh iw 会拷成 .env
├── .env.ow 外网机出厂配置(同上,口令与内网机不同)
├── .env.example 空白模板,只在客户环境差异大到出厂值不适用时才用
├── VERSION.txt 本次出盘的版本/平台/时间/镜像清单
├── config/application.yml 静态默认配置,里面不放任何口令
├── nginx/default.conf.template 前端反代(内/外网共用,靠 SFILE_SITE_ROOT 选站点)
├── sql/01-init-table.sql 全量建表(92K,含 x_file_security)
├── sql/02-init-data.sql 初始数据(2.8K,admin、机构、平台编码)
├── images/*.tar 5 个镜像,共 650M(docker save 原始大小)
├── images/SHA256SUMS.txt 交付盘校验值,install.sh 开机先比对它
└── scripts/
├── install.sh 现场一键安装(要带 iw / ow 参数)
├── smoke_test.py 现场自动化验收(18 条断言)
└── export-images.sh 我方出盘脚本,现场用不到
```
> 仓库里 `images/*.tar`、`SHA256SUMS.txt`、`dist/` 已被 `.gitignore` 排除——镜像 tar 不进气仓,只走交付盘。
镜像清单与已验证版本(tag 必须与 tar 里的完全一致,`install.sh` 会核对 sha256、架构与 tag 三者):
| 镜像 | 交付 tag | tar 大小 | 本机验证版本 | 说明 |
|---|---|---|---|---|
| `sfile-app` | `1.0.0` | 313M | JDK8 运行时 eclipse-temurin:8-jre-jammy | 内含 s-file.jar |
| `sfile-web` | `1.0.0` | 39M | nginx:alpine | **内网包与外网包都在同一个镜像里** |
| `postgres` | `18` | 161M | 18.3 | |
| `redis` | `8` | 53M | 8.6.3 | |
| `rabbitmq` | `3.13-management-alpine` | 84M | 3.13.7 | |
---
## 3. 现场要先定的 6 件事
| 项 | 变量 | 建议 |
|---|---|---|
| 服务器 CPU 架构 | — | `uname -m`:x86_64→amd64,aarch64(鲲鹏/飞腾)→arm64。**出盘前必须确认**,架构不符装不上 |
| 前端对外端口 | `WEB_PORT` | 出厂值 80;被占用改 8080/9080,同步改客户访问地址 |
| 数据盘根目录 | `SFILE_DATA_ROOT` | 独立挂载点,出厂值 `/data/sfile`。上传文件+库+日志都在这,按 500MB/文件上限估:`文件平均大小 × 日均份数 × 保留天数`,一般 ≥200G |
| 光闸摆渡目录 | `GATE_SEND_DIR` / `GATE_RECV_DIR` | 与光闸厂商确认挂载点,**发出/收到是两个不同目录**,两侧机器各自配自己的 |
| MeshSafe 地址与密钥 | `MESHSAFE_BASE_URL` / `MESHSAFE_SECRET` | 同机:`http://host.docker.internal:44380`;独立机:`http://<IP>:<端口>`;`MESHSAFE_SECRET` 出厂是占位值,**接了检测就必须按 MeshSafe 交付单改**,没接就 `MESHSAFE_ENABLED=false` |
| 各类口令 | `POSTGRES_PASSWORD` 等 | 出厂已给随机值可直接跑,正式交付按客户安全要求轮换(见 §5.1) |
机器规格建议:8 核 / 16G 起(应用堆 `-Xmx` 3G,PG + MQ + Redis 还要约 2-3G,留足页缓存),系统盘 100G + 数据盘独立。
---
## 4. 出盘(我方,需要联网一次)
```bash
# 前置:backend/target/s-file.jar 与 frontend/ 已构建
cd deploy/offline
./scripts/export-images.sh 1.0.0 # amd64
TARGET_PLATFORM=linux/arm64 ./scripts/export-images.sh 1.0.0 # 鲲鹏/飞腾
```
脚本会把 `images/*.tar`、`sql/*.sql`、`.env.iw/.env.ow` 的镜像 tag、`SHA256SUMS.txt` 就地刷新,
再打一份 `dist/sfile-offline-<版本>-<平台>.tar.gz` 用于拷盘/刻录。带盘就带这个 gz。
---
## 5. 装机(客户侧,两台机器各执行一次)
```bash
# 1) 解包(两台机器用同一个盘,解出来的目录一模一样)
tar -xzf sfile-offline-1.0.0-linux-amd64.tar.gz -C /opt/
cd /opt/sfile-offline-1.0.0-linux-amd64
# 2) 只改必须改的:MeshSafe 密钥(不接检测就关掉开关)
vi .env.iw # 内网机;外网机改 .env.ow
# 3) 一键装:按角色建 .env -> 自检 -> 校验 tar -> docker load -> 建目录 -> 起栈 -> 等健康
./scripts/install.sh iw # 内网机
./scripts/install.sh ow # 外网机
```
`install.sh` 第一次执行会把 `.env.iw`/`.env.ow` 拷成 `.env`,之后只读 `.env`——现场改动不会被覆盖。
想回到出厂值:`rm .env && ./scripts/install.sh iw`。
参数与已有 `.env` 的角色不一致时直接拒绝,防止在内网机上起了外网栈。
装完会打印访问地址。首次登录后**立刻改掉 admin 的初始口令**。
### 5.1 出厂口令(交付单要抄一份,并建议现场轮换)
| 变量 | 内网机出厂值 | 外网机出厂值 | 现场是否必须改 |
|---|---|---|---|
| `POSTGRES_PASSWORD` | `Sfile-iw-Pg-N1tOz1MxgdaS` | `Sfile-ow-Pg-pT-673--i1rk` | 建议(只在本机容器网络内生效,不对网暴露) |
| `MQ_PASSWORD` | `Sfile-iw-Mq-UTqVSnCDZ0LB` | `Sfile-ow-Mq-sfW5Cv-Nk-cA` | 建议(同上) |
| `JWT_SECRET` | `86a6b015…7d70`(64 位) | `9eeeea03…6988`(64 位) | 必须保密;泄露等于能伪造登录态,两侧已不同 |
| `USER_DEFAULT_PASSWORD` | `Sfile-iw-Usr-FBLS-0fA` | `Sfile-ow-Usr-WqDJ7PDS` | **必须改**:新建用户的初始密码,业务员要知道 |
| admin 登录口令 | 由 `backend/db/init-data.sql` 灌入 | 同左 | **必须改**:装完第一次登录就改 |
| `MESHSAFE_SECRET` | `CHANGE_ME_meshsafe_secret` | `CHANGE_ME_meshsafe_secret` | **故意留空**:以 MeshSafe 侧 `MESHSAFE_API_CLIENTS` 白名单为准,装机前替换 |
> `install.sh` 只对**启用了 MeshSafe 且 secret 还是占位值**的情况卡住;`MESHSAFE_ENABLED=false` 时其余占位值不拦,方便先装后接检测。
### 5.2 内网机 / 外网机配置差异
`.env.iw` 与 `.env.ow` 已按下表分别填好,这里列出来只为让客户网络/安全评审能对上:
| 变量 | 内网机 | 外网机 |
|---|---|---|
| `SFILE_ROLE` | `iw` | `ow` |
| `SFILE_TRANSFER_LOCATION` | `1` | `2` |
| `SFILE_SITE_ROOT` | `/usr/share/nginx/inside` | `/usr/share/nginx/outside` |
| `GATE_SEND_DIR` | `/data/sfile/gate/out`(光闸的**内→外**发出目录) | `/data/sfile/gate/out`(光闸的**外→内**发出目录) |
| `GATE_RECV_DIR` | `/data/sfile/gate/in`(光闸的**外→内**投递目录) | `/data/sfile/gate/in`(光闸的**内→外**投递目录) |
| `MESHSAFE_CHANNEL` / `MESHSAFE_CLIENT_ID` | `sfile-iw` | `sfile-ow` |
| `JWT_SECRET` / `POSTGRES_PASSWORD` / `MQ_PASSWORD` | 一套 | 另一套(已确认不同) |
| `WEB_PORT` | 80 | 80(两台机器各占自己的 80,不冲突) |
| `APP_SUBNET` / `APP_NODE_IP` / `APP_WORK_ID` | `172.30.88.0/24` / `.10` / `1` | `172.30.98.0/24` / `.10` / `2` |
> 两侧网段/机器号故意错开:客户如果先把两台合并成一台机器做演示,不改也能同起。
> `SFILE_TRANSFER_LOCATION` 写反是本方案最容易踩的坑:值对调后,两侧都以为自己是发送端,摆渡目录里的包永远不会被解包。
---
## 6. 验收清单(现场按这个走一遍)
| # | 动作 | 期望 |
|---|---|---|
| 1 | 浏览器开 `http://<内网机IP>:<端口>` | 左上角有「内网」徽标,能登录 |
| 2 | 开 `http://<外网机IP>:<端口>` | 无「内网」徽标(设计上只有内网侧显示标识),能登录 |
| 3 | 内网上传一个正常文件 | 「安全检测」列几秒内从**检测中**变**检测通过** |
| 4 | 内网上传 EICAR 测试串 | 变**检测不通过**,理由含 `Eicar-Test-Signature` 或 `内嵌 EICAR` |
| 5 | 对该文件点分享/传输 | 被拒:`安全检测未通过,禁止分享与传输` |
| 6 | 内网把通过的文件「传输」到外网 | 内网 `GATE_SEND_DIR` 出现 `<n>_<id>.zip` |
| 7 | 等 1 分钟内网监听任务 | 外网「内穿外」目录出现 `<原名>_<时间戳>` 文件夹,文件在内 |
| 8 | 外网该文件的安全检测 | 自动送检并变**检测通过** |
| 9 | 内网对不通过文件做人工复核放行 | 状态变**检测通过**、标记**人工复核**,之后可传输 |
| 10 | `docker compose ps` 两台机各 5 个 healthy | 无 `Restarting`/`unhealthy` |
第 3-9 步可用 `scripts/smoke_test.py` 自动跑(在有 python3 + requests 的机器上,指向客户 IP),现场没条件就手工点。
```bash
SFILE_IW_BASE=http://<内网机IP>:<端口>/api \
SFILE_OW_BASE=http://<外网机IP>:<端口>/api \
SFILE_ADMIN_USER=admin SFILE_ADMIN_PWD='<现场初始口令>' \
python3 scripts/smoke_test.py
```
---
## 7. 运维
```bash
docker compose ps # 状态
docker logs -f sfile-iw-app # 后端日志(含送检/摆渡全过程)
docker compose up -d app # 改完 .env 或 config 后重启后端
docker compose down # 停止(不动数据)
docker compose down -v --remove-orphans # ⚠ 数据在 bind 目录里,不会丢;但别顺手执行
```
**备份只需三样**:`$SFILE_DATA_ROOT/pgdata`(库)、`$SFILE_DATA_ROOT/upload`(文件本体)、`.env`(口令)。
恢复:换机器后原路径放回 + `docker load` + `install.sh`。
数据库升级:新版本的增量 SQL 放 `backend/db/upgrade-*.sql`,人工执行
```bash
docker exec -i sfile-iw-pg psql -U sfile -d s_file < upgrade-20261101-meshsafe-file-security.sql
```
(`sql/01/02` 只在**空库首次启动**执行,已有数据卷会被忽略。)
---
## 8. 已知坑与取舍
1. **占位值检查是有意的,别图省事先注释掉**:`install.sh` 只在「`MESHSAFE_ENABLED=true` 且 `MESHSAFE_SECRET` 还是占位值」时拒绝启动,其余 `CHANGE_ME` 也会拦。没接检测就老实写 `MESHSAFE_ENABLED=false`。
2. **交付盘必须整目录拷**:`install.sh` 会核 5 个 tar 的 sha256、个数、以及 `.env` 里 5 个镜像 tag 是否都能 load 到。只拷脚本不拷 `images/`(tar 不进气仓,见 `.gitignore`)是最常见的现场翻车。
3. **架构不符装不上**:`sfile-app`/`sfile-web` 是在构建机上 `docker build` 出来的,信创 ARM 机必须用 `TARGET_PLATFORM=linux/arm64` 重新出盘(`install.sh` 会拦你)。
4. **PostgreSQL 18 的数据目录挂载点是 `/var/lib/postgresql`**(15 及以前是 `/var/lib/postgresql/data`)。自己换库版本时改 `.env` 的 `PG_DATA_DIR`,否则容器会报「检测到未挂载的数据」并反复重启。
5. **nginx 反代必须透传带端口的 Host**(模板里已用 `$http_host`)。改成 `$host` 会让后端 `RequestUtils.getRootDomain` 对无端口地址抛 `IllegalStateException`,症状就是**登录 500**。
6. **发号器 workerId 必须能取到,否则应用起不来。** 后端按 `code.snowflake.workid.<app容器IP>` 取机器号,取不到时回退默认值 `99`,而 99 超出雪花 5 位上限,启动即 `IllegalArgumentException: worker Id can't be greater than 31`。本方案的做法是把 app 的容器 IP 锁死(`APP_NODE_IP`)并由 compose 自动写进 JVM 参数,`.env` 里 `APP_SUBNET / APP_GATEWAY / APP_NODE_IP` 三个值必须同网段。**只想改代码解决的话**,把 `CodeConfiguration#snowFlakeCodeGenerator` 的兜底 `99L` 改成一个合法值即可解除这层 IP 耦合——但那样同侧多实例会撞 ID,需自行权衡。
7. **MeshSafe 凭证**:`MESHSAFE_CLIENT_ID`/`SECRET` 必须在 MeshSafe 侧 `MESHSAFE_API_CLIENTS` 白名单里存在,否则送检全部 401。`MESHSAFE_FAIL_OPEN=true` 时检测服务挂了不阻断业务(只记失败),等保从严请置 `false`。
8. **`host.docker.internal` 需要 Docker ≥ 20.10**;MeshSafe 在独立服务器时不需要它,可把 compose 里那两行 `extra_hosts` 删掉。
9. **PDF 盖章依赖 spire.pdf 是桩实现**:本机因拿不到商业包做了替代 jar,运行时调用会抛 `UnsupportedOperationException`。交付前若客户要电子签章功能,必须换回 `e-iceblue:spire.pdf:3.7.4` 正式包重新打 jar。
10. **摆渡延迟固定为监听周期**(默认 1 分钟一次)。客户嫌慢就调 `FILE_TRANSFER_MONITOR_CRON`,不要改成秒级——它会整目录扫描。
11. **两侧数据库完全独立**,同一份 `init-data.sql` 灌出来 admin 初始口令一致,交付时两侧都要改。
12. **装完才发现角色装反了**(内网机上跑了 `install.sh ow`):`.env` 与参数角色不一致时 `install.sh` 直接拒绝,所以真发生一定是 `rm .env` 之后重装。compose 项目名跟着角色走,旧项目还在占用 `APP_SUBNET`,会报 `invalid pool request: Pool overlaps with other one on this address space`。先把旧项目拆掉再起:
`docker compose -p sfile-<旧角色> down` → `rm .env` → `./scripts/install.sh <正确角色>`。
13. **检测结论按文件内容复用**:MeshSafe 的 `fileCode` 是内容寻址,同一份字节重复上传会直接沿用上次结论,不会重新扫。这是性能上的设计,但等保评审可能会问「改判是否会被复用绕过」,现场演示验收时请用**每次内容都不同**的测试文件。