31 Commits

Author SHA1 Message Date
93aa73ab5e fix stale frontend cache in install packages 2026-05-20 02:56:44 +08:00
35cd438018 fix filename patch scope 2026-05-20 01:57:22 +08:00
03c56a20db pin reactive resume image digest 2026-05-20 01:41:38 +08:00
5c5d21bf43 add install package smoke test 2026-05-20 01:27:12 +08:00
2141afd3eb support arm64 reactive resume image layout 2026-05-20 01:15:39 +08:00
944a852470 fix install package runtime path detection 2026-05-20 00:49:52 +08:00
009631bd43 update qnap package for me.huijutec.cn 2026-05-20 00:23:06 +08:00
fb149b4e00 publish personal install packages 20260520 2026-05-19 23:58:32 +08:00
c7ed88b2d8 use resume title for pdf download filename 2026-05-19 23:39:50 +08:00
a3569a52e7 2026-05-19-23-23-50 回滚简历PDF下载文件名 2026-05-19 23:28:47 +08:00
d77954ba94 set resume pdf filename to Chinese title 2026-05-19 23:22:24 +08:00
d2edebecda 2026-05-19-23-10-56 修改简历PDF下载文件名 2026-05-19 23:16:57 +08:00
999a1314a8 harden resume filename cache bust patch 2026-05-19 23:15:02 +08:00
602f00262b fix browser pdf buffer polyfill 2026-05-19 23:06:27 +08:00
475bab8bf6 restart app after filename asset patch 2026-05-19 23:00:08 +08:00
beb14bf834 fix reactive resume pdf download filename 2026-05-19 22:52:34 +08:00
fc88270737 fix qnap package root path 2026-05-19 00:33:17 +08:00
63867b4677 rename personal resume packages and add seed data 2026-05-19 00:22:33 +08:00
663143a30a publish reactive resume install packages 2026-05-18 23:47:24 +08:00
f77cf67b76 hide unused resume keywords section 2026-05-18 22:59:11 +08:00
1460b71915 adjust resume sidebar and text spacing 2026-05-18 22:11:18 +08:00
37131e1123 add volunteer activities and compact resume layout 2026-05-18 21:03:59 +08:00
deb999ff4d refine resume typography and spacing 2026-05-18 20:06:37 +08:00
2a2274716e fix reactive resume page loading 2026-05-18 18:02:13 +08:00
92eef39c5a fix resume builder apostrophe parsing 2026-05-18 16:39:05 +08:00
5bb693e8f6 clear reactive resume service worker cache 2026-05-18 16:25:30 +08:00
8a21a46554 fit resume to two pages 2026-05-17 09:47:39 +08:00
54e8f370c8 fix resume pagination and sidebar layout 2026-05-17 01:32:17 +08:00
2c3ce10837 update resume ordering and lab group info 2026-05-17 01:15:05 +08:00
9c65575eb3 refine resume sidebar layout and sharing 2026-05-17 00:53:14 +08:00
dbf7c78405 polish resume awards and patent wording 2026-05-16 01:31:51 +08:00
100 changed files with 17745 additions and 215 deletions

2
.env
View File

@@ -44,7 +44,7 @@ S3_ENDPOINT=
S3_BUCKET=
S3_FORCE_PATH_STYLE=false
REDIS_URL=
ENCRYPTION_SECRET=
ENCRYPTION_SECRET=10ddcad9814eaa5fa5bef8ba85d58a8122f3ede148bad11bc340899b5014af45
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_API_TOKEN=
FLAG_DISABLE_SIGNUPS=false

View File

@@ -13,6 +13,14 @@
- `生成简历/backups/`: 每次线上 `resume` 被替换前导出的历史版本备份。
- `生成简历/visual-assets/`: 图文并茂版简历使用的轻量展示图。
- `reactive_resume_data`: Reactive Resume 本地上传文件持久化 Docker 命名卷。
- `packages/`: 三套可读可改的安装包目录。
- `dist/`: 三套安装包的 `.tar.gz` / `.zip` 发布归档和 `SHA256SUMS`
## 安装包
- `reactive-resume-clean-install-20260520`: 纯净迁移模板域名、端口、FRP、密钥均为待填写占位。
- `reactive-resume-personal-direct-20260520`: 服务器直接运行版,预置 `https://me.huijutec.cn`、FRP `remotePort = 10003`,并内置当前简历与上传图片。
- `reactive-resume-personal-qnap-nas-20260520`: 威联通 QNAP NAS 直接部署版,预置 `/share/Container/reactive_resume`、本地端口 `3003`、FRP `10003`,并内置当前简历与上传图片。
## 启动

View File

@@ -31,7 +31,7 @@ services:
retries: 10
reactive_resume_app:
image: amruthpillai/reactive-resume:latest
image: amruthpillai/reactive-resume@sha256:b760446c4301af067e7d595537a877e378363aa6ce921b7349e62983621826aa
restart: unless-stopped
depends_on:
reactive_resume_permissions:
@@ -97,7 +97,7 @@ services:
# QTS/Container Station 版默认启动 frpc。
# 为避免 QTS 把 frpc 配置文件挂载路径改到应用临时目录,这里不挂载 frpc.yaml
# 而是在容器启动时自动写入 /tmp/frpc.toml。
image: snowdreamtech/frpc:latest
image: fatedier/frpc:v0.68.0
restart: unless-stopped
entrypoint: ["/bin/sh"]
command:

View File

@@ -19,7 +19,7 @@ services:
retries: 10
reactive-resume:
image: amruthpillai/reactive-resume:latest
image: amruthpillai/reactive-resume@sha256:b760446c4301af067e7d595537a877e378363aa6ce921b7349e62983621826aa
restart: unless-stopped
env_file:
- .env

3
dist/SHA256SUMS vendored Normal file
View File

@@ -0,0 +1,3 @@
1a1571bbed2c59c0003daa3c4c41cda7464b03cb8c4706bd7bf507e8abfaa7ea reactive-resume-clean-install-20260520.zip
34764a874e5477e439fa2860ef311e42264e90575c5a89e3f1651748708ef4df reactive-resume-personal-direct-20260520.zip
933541058567f73b6a0d9521863a0d592da6d0a546fd1c38587aeaa0f11cf61d reactive-resume-personal-qnap-nas-20260520.zip

3
dist/SHA256SUMS-20260520 vendored Normal file
View File

@@ -0,0 +1,3 @@
1a1571bbed2c59c0003daa3c4c41cda7464b03cb8c4706bd7bf507e8abfaa7ea reactive-resume-clean-install-20260520.zip
34764a874e5477e439fa2860ef311e42264e90575c5a89e3f1651748708ef4df reactive-resume-personal-direct-20260520.zip
933541058567f73b6a0d9521863a0d592da6d0a546fd1c38587aeaa0f11cf61d reactive-resume-personal-qnap-nas-20260520.zip

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,59 @@
# ===== Reactive Resume clean install environment =====
# Copy this file to .env, then replace every CHANGE_ME / YOUR_* value.
TZ=Asia/Shanghai
# Public URL behind your reverse proxy.
APP_URL=https://YOUR_DOMAIN.example.com
# Local debug binding on this Docker host.
# Use 127.0.0.1 if only frpc / local reverse proxy should reach the app.
LOCAL_BIND_IP=127.0.0.1
LOCAL_APP_PORT=CHANGE_ME_LOCAL_PORT
# PostgreSQL.
POSTGRES_DB=reactive_resume
POSTGRES_USER=reactive_resume
POSTGRES_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD
DATABASE_URL=postgresql://reactive_resume:CHANGE_ME_POSTGRES_PASSWORD@postgres:5432/reactive_resume
# Generate with: openssl rand -hex 32
AUTH_SECRET=CHANGE_ME_64_HEX_AUTH_SECRET
ENCRYPTION_SECRET=CHANGE_ME_64_HEX_ENCRYPTION_SECRET
# Optional email/auth/storage/AI settings can be enabled later.
BETTER_AUTH_API_KEY=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
OAUTH_PROVIDER_NAME=
OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET=
OAUTH_DISCOVERY_URL=
OAUTH_AUTHORIZATION_URL=
OAUTH_TOKEN_URL=
OAUTH_USER_INFO_URL=
OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS=
OAUTH_SCOPES=
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=Reactive Resume <noreply@YOUR_DOMAIN.example.com>
SMTP_SECURE=false
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_REGION=us-east-1
S3_ENDPOINT=
S3_BUCKET=
S3_FORCE_PATH_STYLE=false
REDIS_URL=
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_API_TOKEN=
FLAG_DISABLE_SIGNUPS=false
FLAG_DISABLE_EMAIL_AUTH=false
FLAG_DISABLE_IMAGE_PROCESSING=false
FLAG_ALLOW_UNSAFE_AI_BASE_URL=false

View File

@@ -0,0 +1,38 @@
# Reactive Resume 纯净迁移安装包
这是一套不绑定具体域名、端口、FRP 服务器或密码的纯净安装包,适合迁移到新服务器或新 NAS 前作为模板使用。
## 文件说明
- `compose.yml`:通用 Linux / Docker Compose 部署版
- `compose-Nas.yml`:威联通 QNAP QTS / Container Station 部署模板
- `frpc.yaml`:通用服务器版 frpc 配置模板
- `.env.example`:通用服务器版环境变量模板
## 通用服务器部署
1. 复制环境变量文件:
```bash
cp .env.example .env
```
2. 编辑 `.env` 和 `frpc.yaml`,把所有 `CHANGE_ME` / `YOUR_*` 改成真实值。
3. 启动:
```bash
docker compose -f compose.yml up -d
```
## QNAP NAS 部署
1. 编辑 `compose-Nas.yml` 顶部注释中的路径、域名、端口、FRP 服务器等待填写项。
2. 在 QTS Container Station 中导入 `compose-Nas.yml`。
3. 启动后,确认 app 和 frpc 都为 healthy / running。
## 注意
- `AUTH_SECRET` 和 `ENCRYPTION_SECRET` 必须使用长期固定值,迁移后不要随意更换。
- `APP_URL` 必须与反向代理对外访问域名一致,例如 `https://resume.example.com`。
- FRP 的 `remotePort` 需要与公网服务器 Nginx Proxy Manager / 反向代理转发端口一致。

View File

@@ -0,0 +1,97 @@
# Reactive Resume / QNAP QTS 纯净模板。
# 使用前请填写:
# - NAS 数据目录,例如 /share/Container/Reactive_Resume
# - APP_URL例如 https://resume.example.com
# - 本地访问端口,例如 3004
# - PostgreSQL 密码、AUTH_SECRET、ENCRYPTION_SECRET
# - FRP serverAddr/serverPort/token/proxy name/remotePort
name: reactive-resume-nas
services:
reactive_resume_permissions:
image: alpine:3.20
restart: "no"
command: ["sh", "-c", "mkdir -p /app/data && chown -R 1000:1000 /app/data"]
volumes:
# 待填写:改成你的 NAS 数据目录。
- /share/Container/CHANGE_ME_REACTIVE_RESUME/data/uploads:/app/data
reactive_resume_db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: reactive_resume
POSTGRES_USER: reactive_resume
POSTGRES_PASSWORD: CHANGE_ME_POSTGRES_PASSWORD
volumes:
# 待填写:改成你的 NAS 数据目录。
- /share/Container/CHANGE_ME_REACTIVE_RESUME/data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U reactive_resume -d reactive_resume"]
interval: 10s
timeout: 5s
retries: 10
reactive_resume_app:
image: amruthpillai/reactive-resume@sha256:b760446c4301af067e7d595537a877e378363aa6ce921b7349e62983621826aa
restart: unless-stopped
depends_on:
reactive_resume_permissions:
condition: service_completed_successfully
reactive_resume_db:
condition: service_healthy
ports:
# 待填写NAS 本地访问端口。
- "CHANGE_ME_LOCAL_APP_PORT:3000"
volumes:
# 待填写:改成你的 NAS 数据目录。
- /share/Container/CHANGE_ME_REACTIVE_RESUME/data/uploads:/app/data
environment:
TZ: Asia/Shanghai
APP_URL: https://YOUR_DOMAIN.example.com
DATABASE_URL: postgresql://reactive_resume:CHANGE_ME_POSTGRES_PASSWORD@reactive_resume_db:5432/reactive_resume
AUTH_SECRET: CHANGE_ME_64_HEX_AUTH_SECRET
ENCRYPTION_SECRET: CHANGE_ME_64_HEX_ENCRYPTION_SECRET
SMTP_FROM: Reactive Resume <noreply@YOUR_DOMAIN.example.com>
SMTP_PORT: "587"
SMTP_SECURE: "false"
FLAG_DISABLE_SIGNUPS: "false"
FLAG_DISABLE_EMAIL_AUTH: "false"
FLAG_DISABLE_IMAGE_PROCESSING: "false"
FLAG_ALLOW_UNSAFE_AI_BASE_URL: "false"
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 8
start_period: 30s
reactive_resume_frpc:
image: fatedier/frpc:v0.68.0
restart: unless-stopped
entrypoint: ["/bin/sh"]
command:
- -c
- |
cat > /tmp/frpc.toml <<'EOF'
serverAddr = "YOUR_FRP_SERVER_IP_OR_DOMAIN"
serverPort = CHANGE_ME_FRP_SERVER_PORT
auth.method = "token"
auth.token = "CHANGE_ME_FRP_TOKEN"
transport.poolCount = 5
transport.heartbeatTimeout = -1
[[proxies]]
name = "CHANGE_ME_PROXY_NAME"
type = "tcp"
localIP = "reactive_resume_app"
localPort = 3000
remotePort = CHANGE_ME_REMOTE_PORT
EOF
exec frpc -c /tmp/frpc.toml
depends_on:
reactive_resume_app:
condition: service_healthy

View File

@@ -0,0 +1,72 @@
# Reactive Resume 通用服务器纯净模板。
# 使用前请先复制 .env.example 为 .env并填写域名、端口、数据库密码和密钥。
name: reactive-resume
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- resume_net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
reactive-resume:
image: amruthpillai/reactive-resume@sha256:b760446c4301af067e7d595537a877e378363aa6ce921b7349e62983621826aa
restart: unless-stopped
env_file:
- .env
ports:
# 待填写LOCAL_BIND_IP / LOCAL_APP_PORT 在 .env 中配置。
# 例127.0.0.1:3004:3000仅允许本机反代或 frpc 访问。
- "${LOCAL_BIND_IP}:${LOCAL_APP_PORT}:3000"
volumes:
- reactive_resume_data:/app/data
networks:
- resume_net
depends_on:
postgres:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/api/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1));",
]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
frpc:
image: fatedier/frpc:v0.68.0
restart: unless-stopped
command: ["-c", "/etc/frp/frpc.yaml"]
volumes:
# 待填写frpc.yaml 中配置 FRP 服务器、token、remotePort。
- ./frpc.yaml:/etc/frp/frpc.yaml:ro
networks:
- resume_net
depends_on:
reactive-resume:
condition: service_healthy
networks:
resume_net:
driver: bridge
volumes:
postgres_data:
reactive_resume_data:

View File

@@ -0,0 +1,22 @@
# Reactive Resume frpc 纯净模板。
# 使用前把所有 CHANGE_ME / YOUR_* 改成真实值。
serverAddr: "YOUR_FRP_SERVER_IP_OR_DOMAIN"
serverPort: CHANGE_ME_FRP_SERVER_PORT
auth:
method: "token"
token: "CHANGE_ME_FRP_TOKEN"
transport:
poolCount: 5
heartbeatTimeout: -1
proxies:
- name: "CHANGE_ME_PROXY_NAME"
type: "tcp"
# Docker Compose 内部服务名,通常不用改。
localIP: "reactive-resume"
localPort: 3000
# 待填写:公网 FRP 远程端口,需与反向代理 forward port 一致。
remotePort: CHANGE_ME_REMOTE_PORT

View File

@@ -0,0 +1,50 @@
TZ=Asia/Shanghai
APP_URL=https://me.huijutec.cn
# Local debug access only: http://127.0.0.1:3004
LOCAL_BIND_IP=127.0.0.1
LOCAL_APP_PORT=3004
POSTGRES_DB=reactive_resume
POSTGRES_USER=reactive_resume
POSTGRES_PASSWORD=2ed1869944c609f070699bdf8c92194f
DATABASE_URL=postgresql://reactive_resume:2ed1869944c609f070699bdf8c92194f@postgres:5432/reactive_resume
AUTH_SECRET=9ef1720ee316f9316241bdc84f5dfad99b52f139b48880300942ee61d81b7cda
ENCRYPTION_SECRET=20851888c2a96b11f1f6fc21b4eeb70f1e8258f2d0d414be8bace65eaff289ae
BETTER_AUTH_API_KEY=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
OAUTH_PROVIDER_NAME=
OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET=
OAUTH_DISCOVERY_URL=
OAUTH_AUTHORIZATION_URL=
OAUTH_TOKEN_URL=
OAUTH_USER_INFO_URL=
OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS=
OAUTH_SCOPES=
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=Reactive Resume <noreply@me.huijutec.cn>
SMTP_SECURE=false
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_REGION=us-east-1
S3_ENDPOINT=
S3_BUCKET=
S3_FORCE_PATH_STYLE=false
REDIS_URL=
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_API_TOKEN=
FLAG_DISABLE_SIGNUPS=false
FLAG_DISABLE_EMAIL_AUTH=false
FLAG_DISABLE_IMAGE_PROCESSING=false
FLAG_ALLOW_UNSAFE_AI_BASE_URL=false

View File

@@ -0,0 +1,36 @@
# Reactive Resume 个人简历直接运行安装包
这套包已经按 `https://me.huijutec.cn` 和 FRP `remotePort = 10003` 预置,可以在当前服务器上直接运行。包内已包含当前简历初始化数据、头像和作品集图片,首次启动后可直接访问公开简历。
## 启动
```bash
docker compose -f compose.yml up -d
```
启动后:
- 本机调试地址:`http://127.0.0.1:3004`
- 公网访问地址:`https://me.huijutec.cn`
- 当前公开简历:`https://me.huijutec.cn/audience/resume`
- FRP 映射:本地 `reactive-resume:3000` -> 公网服务器 `10003`
## 反向代理要求
公网服务器上的 Nginx Proxy Manager / 反向代理应配置:
- Domain Names`me.huijutec.cn`
- Scheme`http`
- Forward Hostname / IP`82.157.255.195`
- Forward Port`10003`
- Websockets Support开启
- SSL按现有 huijutec.cn 域名策略配置
## 数据
Compose 会创建独立项目名 `reactive-resume-personal`,默认使用 Docker named volumes
- `reactive-resume-personal_postgres_data`
- `reactive-resume-personal_reactive_resume_data`
`seed/` 目录会在首次启动时导入当前用户、公开简历和上传图片。`patches/` 目录会在应用启动时自动应用当前个人版需要的 PDF 渲染、Glalie 模板排版和“按简历标题下载 PDF”补丁。后续如需迁移数据请备份这些 volumes。

View File

@@ -0,0 +1,97 @@
name: reactive-resume-personal
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- resume_net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
reactive-resume:
image: amruthpillai/reactive-resume@sha256:b760446c4301af067e7d595537a877e378363aa6ce921b7349e62983621826aa
restart: unless-stopped
entrypoint: ["/bin/sh", "/opt/reactive-resume-patches/reactive-resume-entrypoint.sh"]
env_file:
- .env
ports:
- "${LOCAL_BIND_IP}:${LOCAL_APP_PORT}:3000"
volumes:
- reactive_resume_data:/app/data
- ./patches/reactive-resume-runtime-patch.sh:/opt/reactive-resume-patches/reactive-resume-runtime-patch.sh:ro
- ./patches/reactive-resume-entrypoint.sh:/opt/reactive-resume-patches/reactive-resume-entrypoint.sh:ro
networks:
- resume_net
depends_on:
postgres:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/api/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1));",
]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
seed:
image: postgres:16-alpine
restart: "no"
user: "0:0"
entrypoint: ["/bin/sh"]
command:
- -c
- |
set -eu
mkdir -p /app/data/uploads
cp -a /seed/uploads/. /app/data/uploads/
chown -R 1000:1000 /app/data/uploads || true
psql -h postgres -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=1 -f /seed/seed.sql
env_file:
- .env
environment:
PGPASSWORD: ${POSTGRES_PASSWORD}
volumes:
- reactive_resume_data:/app/data
- ./seed:/seed:ro
networks:
- resume_net
depends_on:
postgres:
condition: service_healthy
reactive-resume:
condition: service_healthy
frpc:
image: fatedier/frpc:v0.68.0
restart: unless-stopped
command: ["-c", "/etc/frp/frpc.yaml"]
volumes:
- ./frpc.yaml:/etc/frp/frpc.yaml:ro
networks:
- resume_net
depends_on:
seed:
condition: service_completed_successfully
networks:
resume_net:
driver: bridge
volumes:
postgres_data:
reactive_resume_data:

View File

@@ -0,0 +1,17 @@
serverAddr: "82.157.255.195"
serverPort: 7000
auth:
method: "token"
token: "en.xjtu.edu.cn"
transport:
poolCount: 5
heartbeatTimeout: -1
proxies:
- name: "reactive-resume-personal"
type: "tcp"
localIP: "reactive-resume"
localPort: 3000
remotePort: 10003

View File

@@ -0,0 +1,34 @@
#!/bin/sh
set -eu
PATCH_SCRIPT="/opt/reactive-resume-patches/reactive-resume-runtime-patch.sh"
if [ -f "$PATCH_SCRIPT" ]; then
sh "$PATCH_SCRIPT" || echo "Reactive Resume runtime patch failed, continuing with the image default startup" >&2
fi
if [ "$#" -eq 0 ]; then
if [ -f /app/apps/server/dist/index.mjs ]; then
cd /app
set -- node apps/server/dist/index.mjs
elif [ -f /app/apps/web/.output/server/index.mjs ]; then
cd /app/apps/web
set -- node .output/server/index.mjs
else
server_entry="$(cat /tmp/reactive-resume-server-entry 2>/dev/null || true)"
if [ -n "$server_entry" ] && [ -f "$server_entry" ]; then
cd "$(dirname "$server_entry")"
set -- node "$(basename "$server_entry")"
else
echo "Reactive Resume startup failed: no known server entry found" >&2
find /app -maxdepth 5 \( -name index.mjs -o -name server.js -o -name main.js \) 2>/dev/null | head -50 >&2 || true
exit 1
fi
fi
fi
if command -v docker-entrypoint.sh >/dev/null 2>&1; then
exec docker-entrypoint.sh "$@"
fi
exec "$@"

View File

@@ -0,0 +1,413 @@
#!/bin/sh
set -eu
APP_DIR="${REACTIVE_RESUME_APP_DIR:-}"
SERVER_ENTRY=""
ASSETS_DIR=""
SSR_DIR=""
SERVER_INDEX_FILE=""
SSR_FILE=""
if [ -z "$APP_DIR" ]; then
for candidate in /app/apps/web /app; do
if [ -f "$candidate/.output/server/index.mjs" ]; then
APP_DIR="$candidate"
SERVER_ENTRY="$candidate/.output/server/index.mjs"
ASSETS_DIR="$candidate/.output/public/assets"
SSR_DIR="$candidate/.output/server/_ssr"
SERVER_INDEX_FILE="$SERVER_ENTRY"
break
fi
done
fi
if [ -z "$SERVER_ENTRY" ]; then
index_file="$(find /app -path "*/.output/server/index.mjs" -type f 2>/dev/null | head -n 1 || true)"
if [ -n "$index_file" ]; then
APP_DIR="${index_file%/.output/server/index.mjs}"
SERVER_ENTRY="$index_file"
ASSETS_DIR="$APP_DIR/.output/public/assets"
SSR_DIR="$APP_DIR/.output/server/_ssr"
SERVER_INDEX_FILE="$SERVER_ENTRY"
fi
fi
if [ -z "$SERVER_ENTRY" ] && [ -f /app/apps/server/dist/index.mjs ]; then
APP_DIR="/app"
SERVER_ENTRY="/app/apps/server/dist/index.mjs"
ASSETS_DIR="/app/apps/web/dist/assets"
SERVER_INDEX_FILE="$SERVER_ENTRY"
SSR_FILE="$SERVER_ENTRY"
fi
if [ -z "$SERVER_ENTRY" ] || [ ! -f "$SERVER_ENTRY" ]; then
echo "Reactive Resume runtime patch skipped: server entry not found under /app" >&2
exit 0
fi
printf "%s" "$APP_DIR" > /tmp/reactive-resume-app-dir
printf "%s" "$SERVER_ENTRY" > /tmp/reactive-resume-server-entry
export APP_DIR ASSETS_DIR SSR_DIR SERVER_INDEX_FILE SSR_FILE SERVER_ENTRY
node - <<'NODE'
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const appDir = process.env.APP_DIR || "/app";
const outputDir = path.join(appDir, ".output");
const assetsDir = process.env.ASSETS_DIR || path.join(outputDir, "public/assets");
const ssrDir = process.env.SSR_DIR || "";
const explicitSsrFile = process.env.SSR_FILE || "";
const serverIndexFile = process.env.SERVER_INDEX_FILE || path.join(outputDir, "server/index.mjs");
const filenameCacheBust = "rr-filename-title-20260520b";
const pdfCacheBust = "rr-glalie-layout-20260520";
const appShellSuffix = "rr20260520c";
const browserBufferPolyfill = "var Buffer=globalThis.Buffer??{isBuffer:()=>false,allocUnsafe:e=>new Uint8Array(e),alloc:e=>new Uint8Array(e)};/* rr-browser-buffer-polyfill */";
const serviceWorkerCleanup = `/* Reactive Resume personal deployment: disable stale PWA caches. */
self.addEventListener("install", (event) => {
self.skipWaiting();
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key)))));
});
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
await caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key))));
await self.clients.claim();
})());
});
self.addEventListener("fetch", () => {});
`;
function warn(message) {
console.warn(`Reactive Resume runtime patch: ${message}`);
}
function read(file) {
return fs.readFileSync(file, "utf8");
}
function write(file, source) {
fs.writeFileSync(file, source);
}
function makeEtag(buffer) {
const digest = crypto.createHash("sha1").update(buffer).digest("base64").replace(/=+$/g, "");
return `"${buffer.length.toString(16)}-${digest}"`;
}
function patchStaticManifestEntry(source, urlPath, filePath) {
if (!fs.existsSync(filePath)) return source;
const buffer = fs.readFileSync(filePath);
const startMarker = `"${urlPath}": {`;
const start = source.indexOf(startMarker);
if (start === -1) return source;
const commaEnd = source.indexOf("\n\t},", start);
const objectEnd = source.indexOf("\n\t}", start);
const end = commaEnd === -1 ? objectEnd : Math.min(commaEnd, objectEnd);
if (end === -1) return source;
let entry = source.slice(start, end);
entry = entry
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
.replace(/"size": \d+/, `"size": ${buffer.length}`);
return source.slice(0, start) + entry + source.slice(end);
}
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function listJsFiles(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir)
.filter((name) => name.endsWith(".js"))
.map((name) => path.join(dir, name));
}
function listFilesRecursive(dir, predicate) {
if (!fs.existsSync(dir)) return [];
const result = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const file = path.join(dir, entry.name);
if (entry.isDirectory()) {
result.push(...listFilesRecursive(file, predicate));
} else if (predicate(file)) {
result.push(file);
}
}
return result;
}
function replaceOnce(source, from, to) {
return source.includes(to) ? source : source.replace(from, to);
}
function replaceRegexOnce(source, regex, to) {
return source.includes(to) ? source : source.replace(regex, to);
}
function patchFilenameBundle(file) {
if (!/^file-[A-Za-z0-9_-]+\.js$/.test(path.basename(file))) {
return false;
}
let source = read(file);
const replacement = 'function t(e,t){let n=(e??"resume").toString().trim()||"resume",r=t==null?"":String(t).trim().replace(/^\\./,"");return n=n.replace(/[\\\\/:*?"<>|]/g,"-").replace(/\\s+/g," ").replace(/\\.+$/,"").trim()||"resume",r&&n.toLowerCase().endsWith("."+r.toLowerCase())?n:`${n}${r?`.${r}`:""}`}';
if (source.includes(replacement)) return true;
const start = source.indexOf("function t(");
const end = source.indexOf("function n(", start);
if (start === -1 || end === -1) {
warn(`filename bundle marker not found in ${path.basename(file)}, skipped`);
return false;
}
source = source.slice(0, start) + replacement + source.slice(end);
write(file, source);
return true;
}
function patchSsr(source) {
source = source.replace(/\n\t\tname: "",\n\t\tdata: \{/, "\n\t\tname: resume.name,\n\t\tdata: {");
const filenameReplacement = `function generateFilename(prefix, extension) {\n\tlet filename = (prefix ?? "resume").toString().trim() || "resume";\n\tlet ext = extension == null ? "" : String(extension).trim().replace(/^\\./, "");\n\tfilename = filename.replace(/[\\\\/:*?"<>|]/g, "-").replace(/\\s+/g, " ").replace(/\\.+$/, "").trim() || "resume";\n\treturn ext && filename.toLowerCase().endsWith(\`.\${ext.toLowerCase()}\`) ? filename : \`\${filename}\${ext ? \`.\${ext}\` : ""}\`;\n}`;
if (!source.includes(filenameReplacement)) {
const slugifiedPattern = /function generateFilename\(prefix, extension\) \{\s*return `\$\{slugify\(prefix\)\}\$\{extension \? `\.\$\{extension\}` : ""\}`;\s*\}/;
if (slugifiedPattern.test(source)) {
source = source.replace(slugifiedPattern, filenameReplacement);
} else {
const start = source.indexOf("function generateFilename(");
const end = source.indexOf("\nfunction downloadWithAnchor(", start);
if (start !== -1 && end !== -1) {
source = source.slice(0, start) + filenameReplacement + source.slice(end);
} else {
warn("SSR generateFilename marker not found, skipped");
}
}
}
source = source
.replace(/const sideMargin = bodyLineHeight \* \.(?:2|08);/, "const sideMargin = bodyLineHeight * .08;")
.replace(/metrics\.gapY\(3\.5\)/g, "metrics.gapY(3.0)")
.replace(/metrics\.gapY\(2\.6\)/g, "metrics.gapY(3.0)")
.replace(/metrics\.gapY\(2\.2\)/g, "metrics.gapY(3.0)")
.replace(/style: composeStyles\(styles\.sidebarContent, \{ rowGap: metrics\.sectionGap \}\),/g, "style: composeStyles(styles.sidebarContent, { rowGap: metrics.gapY(3.0) }),")
.replace(/style: composeStyles\(styles\.mainContent, \{ rowGap: metrics\.sectionGap \}\),/g, "style: composeStyles(styles.mainContent, { rowGap: metrics.gapY(3.0) }),")
.replace(/sectionHeading: \{\s*borderBottomWidth: 1,\s*borderBottomColor: primary(?:,\s*paddingBottom: 1(?:\.3)?)?\s*\},/, "sectionHeading: {\n\t\t\t\t\tborderBottomWidth: 1,\n\t\t\t\t\tborderBottomColor: primary,\n\t\t\t\t\tpaddingBottom: 1.3\n\t\t\t\t},");
source = replaceRegexOnce(
source,
/sectionHeading: \{\s*borderBottomWidth: 1,\s*borderBottomColor: primary,\s*paddingBottom: 1(?:\.3)?\s*\},\s*item: \{ rowGap: metrics\.gapY\(\.125\) \},/,
"sectionHeading: {\n\t\t\t\t\tborderBottomWidth: 1,\n\t\t\t\t\tborderBottomColor: primary,\n\t\t\t\t\tpaddingBottom: 1.3\n\t\t\t\t},\n\t\t\t\tsectionItems: { paddingTop: metrics.gapY(.55) },\n\t\t\t\titem: { rowGap: metrics.gapY(.2) },",
);
source = replaceRegexOnce(
source,
/sidebarColumn: \{\s*zIndex: 1,\s*backgroundColor: primaryTint,\s*paddingHorizontal: metrics\.page\.paddingHorizontal,\s*paddingTop: metrics\.page\.paddingVertical,\s*(?:paddingBottom: metrics\.page\.paddingVertical,\s*)?rowGap: (?:metrics\.sectionGap|metrics\.gapY\([^)]+\))\s*\},/,
"sidebarColumn: {\n\t\t\t\t\tzIndex: 1,\n\t\t\t\t\tbackgroundColor: primaryTint,\n\t\t\t\t\tpaddingHorizontal: metrics.page.paddingHorizontal,\n\t\t\t\t\tpaddingTop: metrics.page.paddingVertical,\n\t\t\t\t\tpaddingBottom: metrics.page.paddingVertical,\n\t\t\t\t\trowGap: metrics.gapY(3.0)\n\t\t\t\t},",
);
source = replaceRegexOnce(
source,
/mainContent: \{\s*paddingHorizontal: metrics\.page\.paddingHorizontal,\s*paddingTop: metrics\.page\.paddingVertical,\s*(?:paddingBottom: metrics\.page\.paddingVertical\s*)?\},/,
"mainContent: {\n\t\t\t\t\tpaddingHorizontal: metrics.page.paddingHorizontal,\n\t\t\t\t\tpaddingTop: metrics.page.paddingVertical,\n\t\t\t\t\tpaddingBottom: metrics.page.paddingVertical\n\t\t\t\t},",
);
return source;
}
function patchAppManifest(file, oldBase, newBase) {
if (!fs.existsSync(file)) return false;
let source = read(file);
const next = source.replace(new RegExp(`/assets/${escapeRegex(oldBase)}(?:\\?v=rr-app-shell-[A-Za-z0-9-]+)?`, "g"), `/assets/${newBase}`);
if (next !== source) {
write(file, next);
return true;
}
return false;
}
function cloneStaticManifestEntry(source, oldUrl, newUrl, oldBase, newBase, newFile) {
const marker = `\t"${oldUrl}": {`;
const start = source.indexOf(marker);
if (start === -1) {
warn(`static manifest entry not found for ${oldUrl}, skipped app shell cache bust`);
return source;
}
if (source.includes(`\t"${newUrl}": {`)) {
return patchStaticManifestEntry(source, newUrl, newFile);
}
const close = source.indexOf("\n\t}", start);
if (close === -1 || source[close + 3] !== ",") {
warn(`static manifest entry close marker not found for ${oldUrl}, skipped app shell cache bust`);
return source;
}
let entry = source.slice(start, close + 3);
const buffer = fs.readFileSync(newFile);
entry = entry
.replace(oldUrl, newUrl)
.replace(`../public/assets/${oldBase}`, `../public/assets/${newBase}`)
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
.replace(/"size": \d+/, `"size": ${buffer.length}`);
return source.slice(0, close + 4) + "\n" + entry + "," + source.slice(close + 4);
}
function patchAppShellEntry(assetFiles) {
const indexFile = assetFiles.find((file) => /^index-[A-Za-z0-9_-]+\.js$/.test(path.basename(file)));
if (!indexFile) {
warn("index app shell bundle not found, skipped app shell cache bust");
return null;
}
const oldBase = path.basename(indexFile);
const newBase = oldBase.replace(/\.js$/, `-${appShellSuffix}.js`);
const newFile = path.join(path.dirname(indexFile), newBase);
if (!fs.existsSync(newFile)) {
fs.copyFileSync(indexFile, newFile);
}
return { oldBase, newBase, newFile };
}
function patchAppShellImporters(assetFiles, oldBase, newBase) {
const touched = [];
for (const file of assetFiles) {
if (path.basename(file) === oldBase) continue;
let source = read(file);
let next = source
.replace(new RegExp(`\\./${escapeRegex(oldBase)}(?:\\?v=rr-app-shell-[A-Za-z0-9-]+)?`, "g"), `./${newBase}`)
.replace(new RegExp(`/assets/${escapeRegex(oldBase)}(?:\\?v=rr-app-shell-[A-Za-z0-9-]+)?`, "g"), `/assets/${newBase}`);
if (next !== source) {
write(file, next);
touched.push(file);
}
}
return touched;
}
function patchPublicPdf(source) {
if (!source.includes("rr-browser-buffer-polyfill")) {
const importPrelude = source.match(/^(?:import[^;]+;)+/);
const insertAt = importPrelude ? importPrelude[0].length : source.indexOf(";") + 1;
if (insertAt > 0 && source.startsWith("import")) {
source = source.slice(0, insertAt) + browserBufferPolyfill + source.slice(insertAt);
} else {
warn("PDF bundle import prelude not found, Buffer shim skipped");
}
}
source = source
.replace(/([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\*\.(?:2|08);return\{paragraph:\{marginTop:\1,marginBottom:\1\},listItem:\{marginTop:\1,marginBottom:\1\}\}/, "$1=$2*.08;return{paragraph:{marginTop:$1,marginBottom:$1},listItem:{marginTop:$1,marginBottom:$1}}")
.replace(/[oc]\.gapY\(3\.5\)/g, (m) => `${m[0]}.gapY(3.0)`)
.replace(/[oc]\.gapY\(2\.6\)/g, (m) => `${m[0]}.gapY(3.0)`)
.replace(/[oc]\.gapY\(2\.2\)/g, (m) => `${m[0]}.gapY(3.0)`)
.replace(/style:\$\(a\.sidebarContent,\{rowGap:o\.sectionGap\}\)/g, "style:$(a.sidebarContent,{rowGap:o.gapY(3.0)})")
.replace(/style:\$\(a\.mainContent,\{rowGap:o\.sectionGap\}\)/g, "style:$(a.mainContent,{rowGap:o.gapY(3.0)})")
.replace(/sectionHeading:\{borderBottomWidth:1,borderBottomColor:a(?:,paddingBottom:1(?:\.3)?)?\}/, "sectionHeading:{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1.3}")
.replace(/sectionHeading:\{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1(?:\.3)?\},item:\{rowGap:([a-zA-Z_$][\w$]*)\.gapY\(\.125\)\}/, "sectionHeading:{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1.3},sectionItems:{paddingTop:$1.gapY(.55)},item:{rowGap:$1.gapY(.2)}");
source = replaceOnce(source, "sidebarColumn:{zIndex:1,backgroundColor:o,paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,rowGap:c.sectionGap}", "sidebarColumn:{zIndex:1,backgroundColor:o,paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,paddingBottom:c.page.paddingVertical,rowGap:c.gapY(3.0)}");
source = replaceOnce(source, "mainContent:{paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical}", "mainContent:{paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,paddingBottom:c.page.paddingVertical}");
return source;
}
const assetFiles = listJsFiles(assetsDir);
const filenameFiles = assetFiles
.filter((file) => {
if (!/^file-[A-Za-z0-9_-]+\.js$/.test(path.basename(file))) return false;
const source = read(file);
return source.includes("URL.createObjectURL") && source.includes(".download") && source.includes("revokeObjectURL");
})
.sort((a, b) => fs.statSync(a).size - fs.statSync(b).size);
const patchedFilenameFiles = [];
for (const file of filenameFiles) {
if (patchFilenameBundle(file)) patchedFilenameFiles.push(file);
}
if (patchedFilenameFiles.length === 0) warn("no filename bundle patched");
let ssrFile = "";
if (explicitSsrFile && fs.existsSync(explicitSsrFile) && read(explicitSsrFile).includes("function generateFilename(")) {
ssrFile = explicitSsrFile;
} else if (ssrDir && fs.existsSync(ssrDir)) {
ssrFile = fs.readdirSync(ssrDir)
.filter((name) => name.endsWith(".mjs"))
.map((name) => path.join(ssrDir, name))
.find((file) => read(file).includes("function generateFilename(")) || "";
}
if (ssrFile) {
write(ssrFile, patchSsr(read(ssrFile)));
} else {
warn("SSR bundle with generateFilename not found");
}
const appShell = patchAppShellEntry(assetFiles);
const appShellTouchedImporters = appShell ? patchAppShellImporters(assetFiles, appShell.oldBase, appShell.newBase) : [];
if (appShell) {
const appManifestFiles = [
...listFilesRecursive(path.join(outputDir, "server"), (file) => path.basename(file).startsWith("_tanstack-start-manifest") && file.endsWith(".mjs")),
...listFilesRecursive(path.join(appDir, "apps/server/dist"), (file) => path.basename(file).startsWith("_tanstack-start-manifest") && file.endsWith(".mjs")),
];
for (const file of appManifestFiles) patchAppManifest(file, appShell.oldBase, appShell.newBase);
}
const pdfFile = assetFiles
.filter((file) => path.basename(file).startsWith("pdf-document-"))
.sort((a, b) => fs.statSync(b).size - fs.statSync(a).size)[0] || "";
if (pdfFile) {
write(pdfFile, patchPublicPdf(read(pdfFile)));
} else {
warn("public PDF bundle not found");
}
const filenameBases = patchedFilenameFiles.map((file) => path.basename(file));
const pdfBase = pdfFile ? path.basename(pdfFile) : "";
const touchedImporters = [];
for (const file of assetFiles) {
let source = read(file);
let next = source;
for (const base of filenameBases) {
next = next.replace(new RegExp(`\\./${escapeRegex(base)}(?:\\?v=rr-filename-[A-Za-z0-9-]+)?`, "g"), `./${base}?v=${filenameCacheBust}`);
}
if (pdfBase) {
next = next.replace(new RegExp(`\\./${escapeRegex(pdfBase)}(?:\\?v=rr-[^"'\\\`]+)?`, "g"), `./${pdfBase}?v=${pdfCacheBust}`);
}
if (next !== source) {
write(file, next);
touchedImporters.push(file);
}
}
if (fs.existsSync(serverIndexFile)) {
let serverIndex = read(serverIndexFile);
for (const file of [...patchedFilenameFiles, pdfFile, ...touchedImporters, ...appShellTouchedImporters].filter(Boolean)) {
serverIndex = patchStaticManifestEntry(serverIndex, `/assets/${path.basename(file)}`, file);
}
if (appShell) {
serverIndex = cloneStaticManifestEntry(
serverIndex,
`/assets/${appShell.oldBase}`,
`/assets/${appShell.newBase}`,
appShell.oldBase,
appShell.newBase,
appShell.newFile,
);
}
const serviceWorkerFile = path.join(path.dirname(assetsDir), "sw.js");
if (fs.existsSync(serviceWorkerFile)) {
write(serviceWorkerFile, serviceWorkerCleanup);
serverIndex = patchStaticManifestEntry(serverIndex, "/sw.js", serviceWorkerFile);
}
write(serverIndexFile, serverIndex);
}
for (const file of [...patchedFilenameFiles, pdfFile, ssrFile, serverIndexFile].filter(Boolean)) {
try {
new Function(read(file));
} catch {
// ESM/import bundles are validated by Node at application startup; keep this
// best-effort so a harmless syntax-check limitation never blocks boot.
}
}
NODE

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
# Reactive Resume 个人简历 / QNAP NAS 安装包
这套包面向威联通 QNAP QTS / Container Station已按以下参数预置
- NAS 数据目录:`/share/Container/reactive_resume`
- 本地端口:`3003:3000`
- 公网域名:`https://me.huijutec.cn`
- 当前公开简历:`https://me.huijutec.cn/audience/resume`
- FRP 服务器:`82.157.255.195:7000`
- FRP remotePort`10003`
- 容器运行时代理:`HTTP_PROXY=http://192.168.3.12:7893``HTTPS_PROXY=http://192.168.3.12:7893`,并已配置 `NO_PROXY` 绕过局域网和内部服务名
包内已包含当前简历初始化数据、头像和作品集图片,首次启动后会由 `reactive_resume_seed` 自动导入。
## 部署
1. 将本安装包内容放到 `/share/Container/reactive_resume`
2. 打开 Container Station导入 `/share/Container/reactive_resume/compose-Nas.yml`
3. 启动项目
4. 访问 `https://me.huijutec.cn/audience/resume`
发布归档中的顶层目录已经命名为 `reactive_resume/`,正常解压到 `/share/Container/` 下即可匹配上述路径。
## 反向代理要求
公网服务器上的 Nginx Proxy Manager / 反向代理应配置:
- Domain Names`me.huijutec.cn`
- Scheme`http`
- Forward Hostname / IP`82.157.255.195`
- Forward Port`10003`
- Websockets Support开启
## 数据目录
- PostgreSQL`/share/Container/reactive_resume/data/postgres`
- 上传与本地存储:`/share/Container/reactive_resume/data/uploads`
- 初始化种子:`/share/Container/reactive_resume/seed`
- 运行时补丁:`/share/Container/reactive_resume/patches`

View File

@@ -0,0 +1,180 @@
# Reactive Resume 个人简历 / QNAP QTS 直接部署版。
# 本文件已按 /share/Container/reactive_resume、
# https://me.huijutec.cn、192.168.31.5:3003 本地访问、
# frpc 公网映射 82.157.255.195:10003 预置。
name: reactive-resume-personal-nas
services:
reactive_resume_permissions:
image: alpine:3.20
restart: "no"
environment:
HTTP_PROXY: http://192.168.3.12:7893
HTTPS_PROXY: http://192.168.3.12:7893
http_proxy: http://192.168.3.12:7893
https_proxy: http://192.168.3.12:7893
NO_PROXY: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
no_proxy: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
command: ["sh", "-c", "mkdir -p /app/data && chown -R 1000:1000 /app/data"]
volumes:
- /share/Container/reactive_resume/data/uploads:/app/data
reactive_resume_db:
image: postgres:16-alpine
restart: unless-stopped
environment:
HTTP_PROXY: http://192.168.3.12:7893
HTTPS_PROXY: http://192.168.3.12:7893
http_proxy: http://192.168.3.12:7893
https_proxy: http://192.168.3.12:7893
NO_PROXY: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
no_proxy: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
POSTGRES_DB: reactive_resume
POSTGRES_USER: reactive_resume
POSTGRES_PASSWORD: 5b341c0ca29fefd6d648661150c00fa4
volumes:
- /share/Container/reactive_resume/data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U reactive_resume -d reactive_resume"]
interval: 10s
timeout: 5s
retries: 10
reactive_resume_app:
image: amruthpillai/reactive-resume@sha256:b760446c4301af067e7d595537a877e378363aa6ce921b7349e62983621826aa
restart: unless-stopped
entrypoint: ["/bin/sh", "/opt/reactive-resume-patches/reactive-resume-entrypoint.sh"]
depends_on:
reactive_resume_permissions:
condition: service_completed_successfully
reactive_resume_db:
condition: service_healthy
ports:
- "3003:3000"
volumes:
- /share/Container/reactive_resume/data/uploads:/app/data
- /share/Container/reactive_resume/patches/reactive-resume-runtime-patch.sh:/opt/reactive-resume-patches/reactive-resume-runtime-patch.sh:ro
- /share/Container/reactive_resume/patches/reactive-resume-entrypoint.sh:/opt/reactive-resume-patches/reactive-resume-entrypoint.sh:ro
environment:
HTTP_PROXY: http://192.168.3.12:7893
HTTPS_PROXY: http://192.168.3.12:7893
http_proxy: http://192.168.3.12:7893
https_proxy: http://192.168.3.12:7893
NO_PROXY: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
no_proxy: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
TZ: Asia/Shanghai
APP_URL: https://me.huijutec.cn
DATABASE_URL: postgresql://reactive_resume:5b341c0ca29fefd6d648661150c00fa4@reactive_resume_db:5432/reactive_resume
AUTH_SECRET: c76b0eaf79f731e9ee95918dc69d41696aec9d1deffeabc122944898037bfab1
ENCRYPTION_SECRET: df3a460fa2f92f6e8765927a169322980e18f63a88fbcfedb090819b5afb2408
BETTER_AUTH_API_KEY: ""
GOOGLE_CLIENT_ID: ""
GOOGLE_CLIENT_SECRET: ""
GITHUB_CLIENT_ID: ""
GITHUB_CLIENT_SECRET: ""
LINKEDIN_CLIENT_ID: ""
LINKEDIN_CLIENT_SECRET: ""
OAUTH_PROVIDER_NAME: ""
OAUTH_CLIENT_ID: ""
OAUTH_CLIENT_SECRET: ""
OAUTH_DISCOVERY_URL: ""
OAUTH_AUTHORIZATION_URL: ""
OAUTH_TOKEN_URL: ""
OAUTH_USER_INFO_URL: ""
OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS: ""
OAUTH_SCOPES: ""
SMTP_HOST: ""
SMTP_PORT: "587"
SMTP_USER: ""
SMTP_PASS: ""
SMTP_FROM: "Reactive Resume <noreply@me.huijutec.cn>"
SMTP_SECURE: "false"
S3_ACCESS_KEY_ID: ""
S3_SECRET_ACCESS_KEY: ""
S3_REGION: us-east-1
S3_ENDPOINT: ""
S3_BUCKET: ""
S3_FORCE_PATH_STYLE: "false"
REDIS_URL: ""
CLOUDFLARE_ACCOUNT_ID: ""
CLOUDFLARE_API_TOKEN: ""
FLAG_DISABLE_SIGNUPS: "false"
FLAG_DISABLE_EMAIL_AUTH: "false"
FLAG_DISABLE_IMAGE_PROCESSING: "false"
FLAG_ALLOW_UNSAFE_AI_BASE_URL: "false"
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 8
start_period: 30s
reactive_resume_seed:
image: postgres:16-alpine
restart: "no"
user: "0:0"
entrypoint: ["/bin/sh"]
command:
- -c
- |
set -eu
mkdir -p /app/data/uploads
cp -a /seed/uploads/. /app/data/uploads/
chown -R 1000:1000 /app/data/uploads || true
psql -h reactive_resume_db -U reactive_resume -d reactive_resume -v ON_ERROR_STOP=1 -f /seed/seed.sql
environment:
HTTP_PROXY: http://192.168.3.12:7893
HTTPS_PROXY: http://192.168.3.12:7893
http_proxy: http://192.168.3.12:7893
https_proxy: http://192.168.3.12:7893
NO_PROXY: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
no_proxy: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
PGPASSWORD: 5b341c0ca29fefd6d648661150c00fa4
volumes:
- /share/Container/reactive_resume/data/uploads:/app/data
- /share/Container/reactive_resume/seed:/seed:ro
depends_on:
reactive_resume_permissions:
condition: service_completed_successfully
reactive_resume_db:
condition: service_healthy
reactive_resume_app:
condition: service_healthy
reactive_resume_frpc:
image: fatedier/frpc:v0.68.0
restart: unless-stopped
environment:
HTTP_PROXY: http://192.168.3.12:7893
HTTPS_PROXY: http://192.168.3.12:7893
http_proxy: http://192.168.3.12:7893
https_proxy: http://192.168.3.12:7893
NO_PROXY: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
no_proxy: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
entrypoint: ["/bin/sh"]
command:
- -c
- |
cat > /tmp/frpc.toml <<'EOF'
serverAddr = "82.157.255.195"
serverPort = 7000
auth.method = "token"
auth.token = "en.xjtu.edu.cn"
transport.poolCount = 5
transport.heartbeatTimeout = -1
[[proxies]]
name = "Reactive_Resume_NAS"
type = "tcp"
localIP = "reactive_resume_app"
localPort = 3000
remotePort = 10003
EOF
exec frpc -c /tmp/frpc.toml
depends_on:
reactive_resume_seed:
condition: service_completed_successfully

View File

@@ -0,0 +1,34 @@
#!/bin/sh
set -eu
PATCH_SCRIPT="/opt/reactive-resume-patches/reactive-resume-runtime-patch.sh"
if [ -f "$PATCH_SCRIPT" ]; then
sh "$PATCH_SCRIPT" || echo "Reactive Resume runtime patch failed, continuing with the image default startup" >&2
fi
if [ "$#" -eq 0 ]; then
if [ -f /app/apps/server/dist/index.mjs ]; then
cd /app
set -- node apps/server/dist/index.mjs
elif [ -f /app/apps/web/.output/server/index.mjs ]; then
cd /app/apps/web
set -- node .output/server/index.mjs
else
server_entry="$(cat /tmp/reactive-resume-server-entry 2>/dev/null || true)"
if [ -n "$server_entry" ] && [ -f "$server_entry" ]; then
cd "$(dirname "$server_entry")"
set -- node "$(basename "$server_entry")"
else
echo "Reactive Resume startup failed: no known server entry found" >&2
find /app -maxdepth 5 \( -name index.mjs -o -name server.js -o -name main.js \) 2>/dev/null | head -50 >&2 || true
exit 1
fi
fi
fi
if command -v docker-entrypoint.sh >/dev/null 2>&1; then
exec docker-entrypoint.sh "$@"
fi
exec "$@"

View File

@@ -0,0 +1,413 @@
#!/bin/sh
set -eu
APP_DIR="${REACTIVE_RESUME_APP_DIR:-}"
SERVER_ENTRY=""
ASSETS_DIR=""
SSR_DIR=""
SERVER_INDEX_FILE=""
SSR_FILE=""
if [ -z "$APP_DIR" ]; then
for candidate in /app/apps/web /app; do
if [ -f "$candidate/.output/server/index.mjs" ]; then
APP_DIR="$candidate"
SERVER_ENTRY="$candidate/.output/server/index.mjs"
ASSETS_DIR="$candidate/.output/public/assets"
SSR_DIR="$candidate/.output/server/_ssr"
SERVER_INDEX_FILE="$SERVER_ENTRY"
break
fi
done
fi
if [ -z "$SERVER_ENTRY" ]; then
index_file="$(find /app -path "*/.output/server/index.mjs" -type f 2>/dev/null | head -n 1 || true)"
if [ -n "$index_file" ]; then
APP_DIR="${index_file%/.output/server/index.mjs}"
SERVER_ENTRY="$index_file"
ASSETS_DIR="$APP_DIR/.output/public/assets"
SSR_DIR="$APP_DIR/.output/server/_ssr"
SERVER_INDEX_FILE="$SERVER_ENTRY"
fi
fi
if [ -z "$SERVER_ENTRY" ] && [ -f /app/apps/server/dist/index.mjs ]; then
APP_DIR="/app"
SERVER_ENTRY="/app/apps/server/dist/index.mjs"
ASSETS_DIR="/app/apps/web/dist/assets"
SERVER_INDEX_FILE="$SERVER_ENTRY"
SSR_FILE="$SERVER_ENTRY"
fi
if [ -z "$SERVER_ENTRY" ] || [ ! -f "$SERVER_ENTRY" ]; then
echo "Reactive Resume runtime patch skipped: server entry not found under /app" >&2
exit 0
fi
printf "%s" "$APP_DIR" > /tmp/reactive-resume-app-dir
printf "%s" "$SERVER_ENTRY" > /tmp/reactive-resume-server-entry
export APP_DIR ASSETS_DIR SSR_DIR SERVER_INDEX_FILE SSR_FILE SERVER_ENTRY
node - <<'NODE'
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const appDir = process.env.APP_DIR || "/app";
const outputDir = path.join(appDir, ".output");
const assetsDir = process.env.ASSETS_DIR || path.join(outputDir, "public/assets");
const ssrDir = process.env.SSR_DIR || "";
const explicitSsrFile = process.env.SSR_FILE || "";
const serverIndexFile = process.env.SERVER_INDEX_FILE || path.join(outputDir, "server/index.mjs");
const filenameCacheBust = "rr-filename-title-20260520b";
const pdfCacheBust = "rr-glalie-layout-20260520";
const appShellSuffix = "rr20260520c";
const browserBufferPolyfill = "var Buffer=globalThis.Buffer??{isBuffer:()=>false,allocUnsafe:e=>new Uint8Array(e),alloc:e=>new Uint8Array(e)};/* rr-browser-buffer-polyfill */";
const serviceWorkerCleanup = `/* Reactive Resume personal deployment: disable stale PWA caches. */
self.addEventListener("install", (event) => {
self.skipWaiting();
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key)))));
});
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
await caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key))));
await self.clients.claim();
})());
});
self.addEventListener("fetch", () => {});
`;
function warn(message) {
console.warn(`Reactive Resume runtime patch: ${message}`);
}
function read(file) {
return fs.readFileSync(file, "utf8");
}
function write(file, source) {
fs.writeFileSync(file, source);
}
function makeEtag(buffer) {
const digest = crypto.createHash("sha1").update(buffer).digest("base64").replace(/=+$/g, "");
return `"${buffer.length.toString(16)}-${digest}"`;
}
function patchStaticManifestEntry(source, urlPath, filePath) {
if (!fs.existsSync(filePath)) return source;
const buffer = fs.readFileSync(filePath);
const startMarker = `"${urlPath}": {`;
const start = source.indexOf(startMarker);
if (start === -1) return source;
const commaEnd = source.indexOf("\n\t},", start);
const objectEnd = source.indexOf("\n\t}", start);
const end = commaEnd === -1 ? objectEnd : Math.min(commaEnd, objectEnd);
if (end === -1) return source;
let entry = source.slice(start, end);
entry = entry
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
.replace(/"size": \d+/, `"size": ${buffer.length}`);
return source.slice(0, start) + entry + source.slice(end);
}
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function listJsFiles(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir)
.filter((name) => name.endsWith(".js"))
.map((name) => path.join(dir, name));
}
function listFilesRecursive(dir, predicate) {
if (!fs.existsSync(dir)) return [];
const result = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const file = path.join(dir, entry.name);
if (entry.isDirectory()) {
result.push(...listFilesRecursive(file, predicate));
} else if (predicate(file)) {
result.push(file);
}
}
return result;
}
function replaceOnce(source, from, to) {
return source.includes(to) ? source : source.replace(from, to);
}
function replaceRegexOnce(source, regex, to) {
return source.includes(to) ? source : source.replace(regex, to);
}
function patchFilenameBundle(file) {
if (!/^file-[A-Za-z0-9_-]+\.js$/.test(path.basename(file))) {
return false;
}
let source = read(file);
const replacement = 'function t(e,t){let n=(e??"resume").toString().trim()||"resume",r=t==null?"":String(t).trim().replace(/^\\./,"");return n=n.replace(/[\\\\/:*?"<>|]/g,"-").replace(/\\s+/g," ").replace(/\\.+$/,"").trim()||"resume",r&&n.toLowerCase().endsWith("."+r.toLowerCase())?n:`${n}${r?`.${r}`:""}`}';
if (source.includes(replacement)) return true;
const start = source.indexOf("function t(");
const end = source.indexOf("function n(", start);
if (start === -1 || end === -1) {
warn(`filename bundle marker not found in ${path.basename(file)}, skipped`);
return false;
}
source = source.slice(0, start) + replacement + source.slice(end);
write(file, source);
return true;
}
function patchSsr(source) {
source = source.replace(/\n\t\tname: "",\n\t\tdata: \{/, "\n\t\tname: resume.name,\n\t\tdata: {");
const filenameReplacement = `function generateFilename(prefix, extension) {\n\tlet filename = (prefix ?? "resume").toString().trim() || "resume";\n\tlet ext = extension == null ? "" : String(extension).trim().replace(/^\\./, "");\n\tfilename = filename.replace(/[\\\\/:*?"<>|]/g, "-").replace(/\\s+/g, " ").replace(/\\.+$/, "").trim() || "resume";\n\treturn ext && filename.toLowerCase().endsWith(\`.\${ext.toLowerCase()}\`) ? filename : \`\${filename}\${ext ? \`.\${ext}\` : ""}\`;\n}`;
if (!source.includes(filenameReplacement)) {
const slugifiedPattern = /function generateFilename\(prefix, extension\) \{\s*return `\$\{slugify\(prefix\)\}\$\{extension \? `\.\$\{extension\}` : ""\}`;\s*\}/;
if (slugifiedPattern.test(source)) {
source = source.replace(slugifiedPattern, filenameReplacement);
} else {
const start = source.indexOf("function generateFilename(");
const end = source.indexOf("\nfunction downloadWithAnchor(", start);
if (start !== -1 && end !== -1) {
source = source.slice(0, start) + filenameReplacement + source.slice(end);
} else {
warn("SSR generateFilename marker not found, skipped");
}
}
}
source = source
.replace(/const sideMargin = bodyLineHeight \* \.(?:2|08);/, "const sideMargin = bodyLineHeight * .08;")
.replace(/metrics\.gapY\(3\.5\)/g, "metrics.gapY(3.0)")
.replace(/metrics\.gapY\(2\.6\)/g, "metrics.gapY(3.0)")
.replace(/metrics\.gapY\(2\.2\)/g, "metrics.gapY(3.0)")
.replace(/style: composeStyles\(styles\.sidebarContent, \{ rowGap: metrics\.sectionGap \}\),/g, "style: composeStyles(styles.sidebarContent, { rowGap: metrics.gapY(3.0) }),")
.replace(/style: composeStyles\(styles\.mainContent, \{ rowGap: metrics\.sectionGap \}\),/g, "style: composeStyles(styles.mainContent, { rowGap: metrics.gapY(3.0) }),")
.replace(/sectionHeading: \{\s*borderBottomWidth: 1,\s*borderBottomColor: primary(?:,\s*paddingBottom: 1(?:\.3)?)?\s*\},/, "sectionHeading: {\n\t\t\t\t\tborderBottomWidth: 1,\n\t\t\t\t\tborderBottomColor: primary,\n\t\t\t\t\tpaddingBottom: 1.3\n\t\t\t\t},");
source = replaceRegexOnce(
source,
/sectionHeading: \{\s*borderBottomWidth: 1,\s*borderBottomColor: primary,\s*paddingBottom: 1(?:\.3)?\s*\},\s*item: \{ rowGap: metrics\.gapY\(\.125\) \},/,
"sectionHeading: {\n\t\t\t\t\tborderBottomWidth: 1,\n\t\t\t\t\tborderBottomColor: primary,\n\t\t\t\t\tpaddingBottom: 1.3\n\t\t\t\t},\n\t\t\t\tsectionItems: { paddingTop: metrics.gapY(.55) },\n\t\t\t\titem: { rowGap: metrics.gapY(.2) },",
);
source = replaceRegexOnce(
source,
/sidebarColumn: \{\s*zIndex: 1,\s*backgroundColor: primaryTint,\s*paddingHorizontal: metrics\.page\.paddingHorizontal,\s*paddingTop: metrics\.page\.paddingVertical,\s*(?:paddingBottom: metrics\.page\.paddingVertical,\s*)?rowGap: (?:metrics\.sectionGap|metrics\.gapY\([^)]+\))\s*\},/,
"sidebarColumn: {\n\t\t\t\t\tzIndex: 1,\n\t\t\t\t\tbackgroundColor: primaryTint,\n\t\t\t\t\tpaddingHorizontal: metrics.page.paddingHorizontal,\n\t\t\t\t\tpaddingTop: metrics.page.paddingVertical,\n\t\t\t\t\tpaddingBottom: metrics.page.paddingVertical,\n\t\t\t\t\trowGap: metrics.gapY(3.0)\n\t\t\t\t},",
);
source = replaceRegexOnce(
source,
/mainContent: \{\s*paddingHorizontal: metrics\.page\.paddingHorizontal,\s*paddingTop: metrics\.page\.paddingVertical,\s*(?:paddingBottom: metrics\.page\.paddingVertical\s*)?\},/,
"mainContent: {\n\t\t\t\t\tpaddingHorizontal: metrics.page.paddingHorizontal,\n\t\t\t\t\tpaddingTop: metrics.page.paddingVertical,\n\t\t\t\t\tpaddingBottom: metrics.page.paddingVertical\n\t\t\t\t},",
);
return source;
}
function patchAppManifest(file, oldBase, newBase) {
if (!fs.existsSync(file)) return false;
let source = read(file);
const next = source.replace(new RegExp(`/assets/${escapeRegex(oldBase)}(?:\\?v=rr-app-shell-[A-Za-z0-9-]+)?`, "g"), `/assets/${newBase}`);
if (next !== source) {
write(file, next);
return true;
}
return false;
}
function cloneStaticManifestEntry(source, oldUrl, newUrl, oldBase, newBase, newFile) {
const marker = `\t"${oldUrl}": {`;
const start = source.indexOf(marker);
if (start === -1) {
warn(`static manifest entry not found for ${oldUrl}, skipped app shell cache bust`);
return source;
}
if (source.includes(`\t"${newUrl}": {`)) {
return patchStaticManifestEntry(source, newUrl, newFile);
}
const close = source.indexOf("\n\t}", start);
if (close === -1 || source[close + 3] !== ",") {
warn(`static manifest entry close marker not found for ${oldUrl}, skipped app shell cache bust`);
return source;
}
let entry = source.slice(start, close + 3);
const buffer = fs.readFileSync(newFile);
entry = entry
.replace(oldUrl, newUrl)
.replace(`../public/assets/${oldBase}`, `../public/assets/${newBase}`)
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
.replace(/"size": \d+/, `"size": ${buffer.length}`);
return source.slice(0, close + 4) + "\n" + entry + "," + source.slice(close + 4);
}
function patchAppShellEntry(assetFiles) {
const indexFile = assetFiles.find((file) => /^index-[A-Za-z0-9_-]+\.js$/.test(path.basename(file)));
if (!indexFile) {
warn("index app shell bundle not found, skipped app shell cache bust");
return null;
}
const oldBase = path.basename(indexFile);
const newBase = oldBase.replace(/\.js$/, `-${appShellSuffix}.js`);
const newFile = path.join(path.dirname(indexFile), newBase);
if (!fs.existsSync(newFile)) {
fs.copyFileSync(indexFile, newFile);
}
return { oldBase, newBase, newFile };
}
function patchAppShellImporters(assetFiles, oldBase, newBase) {
const touched = [];
for (const file of assetFiles) {
if (path.basename(file) === oldBase) continue;
let source = read(file);
let next = source
.replace(new RegExp(`\\./${escapeRegex(oldBase)}(?:\\?v=rr-app-shell-[A-Za-z0-9-]+)?`, "g"), `./${newBase}`)
.replace(new RegExp(`/assets/${escapeRegex(oldBase)}(?:\\?v=rr-app-shell-[A-Za-z0-9-]+)?`, "g"), `/assets/${newBase}`);
if (next !== source) {
write(file, next);
touched.push(file);
}
}
return touched;
}
function patchPublicPdf(source) {
if (!source.includes("rr-browser-buffer-polyfill")) {
const importPrelude = source.match(/^(?:import[^;]+;)+/);
const insertAt = importPrelude ? importPrelude[0].length : source.indexOf(";") + 1;
if (insertAt > 0 && source.startsWith("import")) {
source = source.slice(0, insertAt) + browserBufferPolyfill + source.slice(insertAt);
} else {
warn("PDF bundle import prelude not found, Buffer shim skipped");
}
}
source = source
.replace(/([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\*\.(?:2|08);return\{paragraph:\{marginTop:\1,marginBottom:\1\},listItem:\{marginTop:\1,marginBottom:\1\}\}/, "$1=$2*.08;return{paragraph:{marginTop:$1,marginBottom:$1},listItem:{marginTop:$1,marginBottom:$1}}")
.replace(/[oc]\.gapY\(3\.5\)/g, (m) => `${m[0]}.gapY(3.0)`)
.replace(/[oc]\.gapY\(2\.6\)/g, (m) => `${m[0]}.gapY(3.0)`)
.replace(/[oc]\.gapY\(2\.2\)/g, (m) => `${m[0]}.gapY(3.0)`)
.replace(/style:\$\(a\.sidebarContent,\{rowGap:o\.sectionGap\}\)/g, "style:$(a.sidebarContent,{rowGap:o.gapY(3.0)})")
.replace(/style:\$\(a\.mainContent,\{rowGap:o\.sectionGap\}\)/g, "style:$(a.mainContent,{rowGap:o.gapY(3.0)})")
.replace(/sectionHeading:\{borderBottomWidth:1,borderBottomColor:a(?:,paddingBottom:1(?:\.3)?)?\}/, "sectionHeading:{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1.3}")
.replace(/sectionHeading:\{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1(?:\.3)?\},item:\{rowGap:([a-zA-Z_$][\w$]*)\.gapY\(\.125\)\}/, "sectionHeading:{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1.3},sectionItems:{paddingTop:$1.gapY(.55)},item:{rowGap:$1.gapY(.2)}");
source = replaceOnce(source, "sidebarColumn:{zIndex:1,backgroundColor:o,paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,rowGap:c.sectionGap}", "sidebarColumn:{zIndex:1,backgroundColor:o,paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,paddingBottom:c.page.paddingVertical,rowGap:c.gapY(3.0)}");
source = replaceOnce(source, "mainContent:{paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical}", "mainContent:{paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,paddingBottom:c.page.paddingVertical}");
return source;
}
const assetFiles = listJsFiles(assetsDir);
const filenameFiles = assetFiles
.filter((file) => {
if (!/^file-[A-Za-z0-9_-]+\.js$/.test(path.basename(file))) return false;
const source = read(file);
return source.includes("URL.createObjectURL") && source.includes(".download") && source.includes("revokeObjectURL");
})
.sort((a, b) => fs.statSync(a).size - fs.statSync(b).size);
const patchedFilenameFiles = [];
for (const file of filenameFiles) {
if (patchFilenameBundle(file)) patchedFilenameFiles.push(file);
}
if (patchedFilenameFiles.length === 0) warn("no filename bundle patched");
let ssrFile = "";
if (explicitSsrFile && fs.existsSync(explicitSsrFile) && read(explicitSsrFile).includes("function generateFilename(")) {
ssrFile = explicitSsrFile;
} else if (ssrDir && fs.existsSync(ssrDir)) {
ssrFile = fs.readdirSync(ssrDir)
.filter((name) => name.endsWith(".mjs"))
.map((name) => path.join(ssrDir, name))
.find((file) => read(file).includes("function generateFilename(")) || "";
}
if (ssrFile) {
write(ssrFile, patchSsr(read(ssrFile)));
} else {
warn("SSR bundle with generateFilename not found");
}
const appShell = patchAppShellEntry(assetFiles);
const appShellTouchedImporters = appShell ? patchAppShellImporters(assetFiles, appShell.oldBase, appShell.newBase) : [];
if (appShell) {
const appManifestFiles = [
...listFilesRecursive(path.join(outputDir, "server"), (file) => path.basename(file).startsWith("_tanstack-start-manifest") && file.endsWith(".mjs")),
...listFilesRecursive(path.join(appDir, "apps/server/dist"), (file) => path.basename(file).startsWith("_tanstack-start-manifest") && file.endsWith(".mjs")),
];
for (const file of appManifestFiles) patchAppManifest(file, appShell.oldBase, appShell.newBase);
}
const pdfFile = assetFiles
.filter((file) => path.basename(file).startsWith("pdf-document-"))
.sort((a, b) => fs.statSync(b).size - fs.statSync(a).size)[0] || "";
if (pdfFile) {
write(pdfFile, patchPublicPdf(read(pdfFile)));
} else {
warn("public PDF bundle not found");
}
const filenameBases = patchedFilenameFiles.map((file) => path.basename(file));
const pdfBase = pdfFile ? path.basename(pdfFile) : "";
const touchedImporters = [];
for (const file of assetFiles) {
let source = read(file);
let next = source;
for (const base of filenameBases) {
next = next.replace(new RegExp(`\\./${escapeRegex(base)}(?:\\?v=rr-filename-[A-Za-z0-9-]+)?`, "g"), `./${base}?v=${filenameCacheBust}`);
}
if (pdfBase) {
next = next.replace(new RegExp(`\\./${escapeRegex(pdfBase)}(?:\\?v=rr-[^"'\\\`]+)?`, "g"), `./${pdfBase}?v=${pdfCacheBust}`);
}
if (next !== source) {
write(file, next);
touchedImporters.push(file);
}
}
if (fs.existsSync(serverIndexFile)) {
let serverIndex = read(serverIndexFile);
for (const file of [...patchedFilenameFiles, pdfFile, ...touchedImporters, ...appShellTouchedImporters].filter(Boolean)) {
serverIndex = patchStaticManifestEntry(serverIndex, `/assets/${path.basename(file)}`, file);
}
if (appShell) {
serverIndex = cloneStaticManifestEntry(
serverIndex,
`/assets/${appShell.oldBase}`,
`/assets/${appShell.newBase}`,
appShell.oldBase,
appShell.newBase,
appShell.newFile,
);
}
const serviceWorkerFile = path.join(path.dirname(assetsDir), "sw.js");
if (fs.existsSync(serviceWorkerFile)) {
write(serviceWorkerFile, serviceWorkerCleanup);
serverIndex = patchStaticManifestEntry(serverIndex, "/sw.js", serviceWorkerFile);
}
write(serverIndexFile, serverIndex);
}
for (const file of [...patchedFilenameFiles, pdfFile, ssrFile, serverIndexFile].filter(Boolean)) {
try {
new Function(read(file));
} catch {
// ESM/import bundles are validated by Node at application startup; keep this
// best-effort so a harmless syntax-check limitation never blocks boot.
}
}
NODE

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,121 @@
#!/bin/sh
set -eu
CONTAINER="${1:-reactive-resume-reactive-resume-1}"
docker exec -u root -i "$CONTAINER" sh <<'SH'
set -eu
PUBLIC_FILE="/app/apps/web/.output/public/assets/file-D5WsIgJH.js"
SSR_FILE="/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs"
SERVER_INDEX_FILE="/app/apps/web/.output/server/index.mjs"
CACHE_BUST="rr-filename-title-20260519"
cp "$PUBLIC_FILE" "$PUBLIC_FILE.bak-filename" 2>/dev/null || true
cp "$SSR_FILE" "$SSR_FILE.bak-filename" 2>/dev/null || true
cp "$SERVER_INDEX_FILE" "$SERVER_INDEX_FILE.bak-filename" 2>/dev/null || true
node - <<'NODE'
const fs = require('fs');
const crypto = require('crypto');
const publicFile = '/app/apps/web/.output/public/assets/file-D5WsIgJH.js';
const ssrFile = '/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs';
const serverIndexFile = '/app/apps/web/.output/server/index.mjs';
const cacheBust = 'rr-filename-title-20260519';
function makeEtag(buffer) {
const digest = crypto.createHash('sha1').update(buffer).digest('base64').replace(/=+$/g, '');
return `"${buffer.length.toString(16)}-${digest}"`;
}
function patchStaticManifestEntry(source, urlPath, filePath) {
const buffer = fs.readFileSync(filePath);
const startMarker = `"${urlPath}": {`;
const start = source.indexOf(startMarker);
if (start === -1) throw new Error(`Static manifest entry not found for ${urlPath}`);
const commaEnd = source.indexOf('\n\t},', start);
const objectEnd = source.indexOf('\n\t}', start);
const end = commaEnd === -1 ? objectEnd : Math.min(commaEnd, objectEnd);
if (end === -1) throw new Error(`Static manifest entry end not found for ${urlPath}`);
let entry = source.slice(start, end);
entry = entry
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
.replace(/"size": \d+/, `"size": ${buffer.length}`);
return source.slice(0, start) + entry + source.slice(end);
}
function patchPublicImporters() {
const assetsDir = '/app/apps/web/.output/public/assets';
const files = fs
.readdirSync(assetsDir)
.filter((name) => name.endsWith('.js'))
.map((name) => `${assetsDir}/${name}`)
.filter((file) => fs.readFileSync(file, 'utf8').includes('file-D5WsIgJH.js'));
for (const file of files) {
let source = fs.readFileSync(file, 'utf8');
source = source.replace(/\.\/file-D5WsIgJH\.js(?:\?v=rr-filename-[A-Za-z0-9-]+)?/g, `./file-D5WsIgJH.js?v=${cacheBust}`);
fs.writeFileSync(file, source);
}
return files;
}
const publicFilenameFunction = 'function t(e,t){let n=(e||"resume").toString().trim()||"resume";return n=n.replace(/[\\\\/:*?"<>|]/g,"-").replace(/\\s+/g," ").replace(/\\.+$/,"").trim()||"resume",t&&n.toLowerCase().endsWith("."+t.toLowerCase())?n:`${n}${t?`.${t}`:""}`}';
let publicJs = fs.readFileSync(publicFile, 'utf8');
const publicReplacement = publicFilenameFunction;
if (!publicJs.includes(publicReplacement)) {
const start = publicJs.indexOf('function t(');
const end = publicJs.indexOf('function n(', start);
if (start === -1 || end === -1) throw new Error('Public generateFilename marker not found');
publicJs = publicJs.slice(0, start) + publicReplacement + publicJs.slice(end);
}
fs.writeFileSync(publicFile, publicJs);
let ssr = fs.readFileSync(ssrFile, 'utf8');
ssr = ssr.replace(
/\n\t\tname: "",\n\t\tdata: \{/,
'\n\t\tname: resume.name,\n\t\tdata: {',
);
const ssrReplacement = `function generateFilename(prefix, extension) {\n\tlet filename = (prefix || "resume").toString().trim() || "resume";\n\tfilename = filename.replace(/[\\\\/:*?"<>|]/g, "-").replace(/\\s+/g, " ").replace(/\\.+$/, "").trim() || "resume";\n\treturn extension && filename.toLowerCase().endsWith(\`.\${extension.toLowerCase()}\`) ? filename : \`\${filename}\${extension ? \`.\${extension}\` : ""}\`;\n}`;
if (!ssr.includes(ssrReplacement)) {
const start = ssr.indexOf('function generateFilename(');
const end = ssr.indexOf('\nfunction downloadWithAnchor(', start);
if (start === -1 || end === -1) throw new Error('SSR generateFilename marker not found');
ssr = ssr.slice(0, start) + ssrReplacement + ssr.slice(end);
}
fs.writeFileSync(ssrFile, ssr);
const importers = patchPublicImporters();
let serverIndex = fs.readFileSync(serverIndexFile, 'utf8');
serverIndex = patchStaticManifestEntry(serverIndex, '/assets/file-D5WsIgJH.js', publicFile);
for (const file of importers) {
const urlPath = `/assets/${file.split('/').pop()}`;
serverIndex = patchStaticManifestEntry(serverIndex, urlPath, file);
}
fs.writeFileSync(serverIndexFile, serverIndex);
NODE
node --check "$PUBLIC_FILE" >/dev/null
node --check "$SSR_FILE" >/dev/null
node --check "$SERVER_INDEX_FILE" >/dev/null
SH
# Nitro loads the static asset manifest into memory at process startup. Restart
# after patching so updated content-length/etag values are used immediately.
docker restart "$CONTAINER" >/dev/null
for _ in $(seq 1 60); do
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$CONTAINER")"
[ "$health" = "healthy" ] && exit 0
sleep 2
done
docker logs --tail 80 "$CONTAINER" >&2
exit 1

View File

@@ -0,0 +1,236 @@
#!/bin/sh
set -eu
CONTAINER="${1:-reactive-resume-reactive-resume-1}"
docker exec -u root -i "$CONTAINER" sh <<'SH'
set -eu
SSR_FILE="/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs"
PUBLIC_FILE="/app/apps/web/.output/public/assets/pdf-document-BplbXx-0.js"
SERVER_INDEX_FILE="/app/apps/web/.output/server/index.mjs"
CACHE_BUST="rr-glalie-layout-20260519"
test -f "$SSR_FILE.bak-glalie-layout" || cp "$SSR_FILE" "$SSR_FILE.bak-glalie-layout" 2>/dev/null || true
test -f "$PUBLIC_FILE.bak-glalie-layout" || cp "$PUBLIC_FILE" "$PUBLIC_FILE.bak-glalie-layout" 2>/dev/null || true
test -f "$SERVER_INDEX_FILE.bak-glalie-layout" || cp "$SERVER_INDEX_FILE" "$SERVER_INDEX_FILE.bak-glalie-layout" 2>/dev/null || true
node - <<'NODE'
const fs = require("fs");
const crypto = require("crypto");
const ssrFile = "/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs";
const publicFile = "/app/apps/web/.output/public/assets/pdf-document-BplbXx-0.js";
const serverIndexFile = "/app/apps/web/.output/server/index.mjs";
const cacheBust = "rr-glalie-layout-20260519";
const browserBufferPolyfill = "var Buffer=globalThis.Buffer??{isBuffer:()=>false,allocUnsafe:e=>new Uint8Array(e),alloc:e=>new Uint8Array(e)};/* rr-browser-buffer-polyfill */";
function replaceOnce(source, from, to, label) {
if (source.includes(to)) return source;
if (!source.includes(from)) throw new Error(`Patch marker not found: ${label}`);
return source.replace(from, to);
}
function replaceRegexOnce(source, regex, to, label) {
if (source.includes(to)) return source;
const next = source.replace(regex, to);
if (next === source) throw new Error(`Patch marker not found: ${label}`);
return next;
}
function patchSsr(source) {
source = source
.replace(/const sideMargin = bodyLineHeight \* \.(?:2|08);/, "const sideMargin = bodyLineHeight * .08;")
.replace(/metrics\.gapY\(3\.5\)/g, "metrics.gapY(3.0)")
.replace(/metrics\.gapY\(2\.6\)/g, "metrics.gapY(3.0)")
.replace(/metrics\.gapY\(2\.2\)/g, "metrics.gapY(3.0)");
source = source
.replace(
/style: composeStyles\(styles\.sidebarContent, \{ rowGap: metrics\.sectionGap \}\),/g,
"style: composeStyles(styles.sidebarContent, { rowGap: metrics.gapY(2.2) }),",
)
.replace(
/style: composeStyles\(styles\.mainContent, \{ rowGap: metrics\.sectionGap \}\),/g,
"style: composeStyles(styles.mainContent, { rowGap: metrics.gapY(2.2) }),",
);
source = source.replace(
/sectionHeading: \{\s*borderBottomWidth: 1,\s*borderBottomColor: primary(?:,\s*paddingBottom: 1(?:\.3)?)?\s*\},/,
`sectionHeading: {
\t\t\t\t\tborderBottomWidth: 1,
\t\t\t\t\tborderBottomColor: primary,
\t\t\t\t\tpaddingBottom: 1.3
\t\t\t\t},`,
);
source = replaceRegexOnce(
source,
/sectionHeading: \{\s*borderBottomWidth: 1,\s*borderBottomColor: primary,\s*paddingBottom: 1(?:\.3)?\s*\},\s*item: \{ rowGap: metrics\.gapY\(\.125\) \},/,
`sectionHeading: {
\t\t\t\t\tborderBottomWidth: 1,
\t\t\t\t\tborderBottomColor: primary,
\t\t\t\t\tpaddingBottom: 1.3
\t\t\t\t},
\t\t\t\tsectionItems: { paddingTop: metrics.gapY(.55) },
\t\t\t\titem: { rowGap: metrics.gapY(.2) },`,
"SSR Glalie section item spacing",
);
source = replaceRegexOnce(
source,
/sidebarColumn: \{\s*zIndex: 1,\s*backgroundColor: primaryTint,\s*paddingHorizontal: metrics\.page\.paddingHorizontal,\s*paddingTop: metrics\.page\.paddingVertical,\s*(?:paddingBottom: metrics\.page\.paddingVertical,\s*)?rowGap: (?:metrics\.sectionGap|metrics\.gapY\([^)]+\))\s*\},/,
`sidebarColumn: {
\t\t\t\t\tzIndex: 1,
\t\t\t\t\tbackgroundColor: primaryTint,
\t\t\t\t\tpaddingHorizontal: metrics.page.paddingHorizontal,
\t\t\t\t\tpaddingTop: metrics.page.paddingVertical,
\t\t\t\t\tpaddingBottom: metrics.page.paddingVertical,
\t\t\t\t\trowGap: metrics.gapY(3.0)
\t\t\t\t},`,
"SSR Glalie sidebar bottom padding",
);
source = replaceRegexOnce(
source,
/mainContent: \{\s*paddingHorizontal: metrics\.page\.paddingHorizontal,\s*paddingTop: metrics\.page\.paddingVertical,\s*(?:paddingBottom: metrics\.page\.paddingVertical\s*)?\},/,
`mainContent: {
\t\t\t\t\tpaddingHorizontal: metrics.page.paddingHorizontal,
\t\t\t\t\tpaddingTop: metrics.page.paddingVertical,
\t\t\t\t\tpaddingBottom: metrics.page.paddingVertical
\t\t\t\t},`,
"SSR Glalie main bottom padding",
);
return source
.replace(/const sideMargin = bodyLineHeight \* \.(?:2|08);/, "const sideMargin = bodyLineHeight * .08;")
.replace(/metrics\.gapY\(3\.5\)/g, "metrics.gapY(3.0)")
.replace(/metrics\.gapY\(2\.6\)/g, "metrics.gapY(3.0)")
.replace(/metrics\.gapY\(2\.2\)/g, "metrics.gapY(3.0)");
}
function ensureBrowserBufferPolyfill(source) {
if (source.includes("rr-browser-buffer-polyfill")) return source;
const insertAt = source.indexOf(";") + 1;
if (insertAt <= 0 || !source.startsWith("import")) {
throw new Error("public PDF bundle import prelude not found");
}
return source.slice(0, insertAt) + browserBufferPolyfill + source.slice(insertAt);
}
function patchPublic(source) {
source = ensureBrowserBufferPolyfill(source);
source = source
.replace(/([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\*\.(?:2|08);return\{paragraph:\{marginTop:\1,marginBottom:\1\},listItem:\{marginTop:\1,marginBottom:\1\}\}/,
"$1=$2*.08;return{paragraph:{marginTop:$1,marginBottom:$1},listItem:{marginTop:$1,marginBottom:$1}}")
.replace(/o\.gapY\(3\.5\)/g, "o.gapY(3.0)")
.replace(/c\.gapY\(3\.5\)/g, "c.gapY(3.0)")
.replace(/o\.gapY\(2\.6\)/g, "o.gapY(3.0)")
.replace(/c\.gapY\(2\.6\)/g, "c.gapY(3.0)")
.replace(/o\.gapY\(2\.2\)/g, "o.gapY(3.0)")
.replace(/c\.gapY\(2\.2\)/g, "c.gapY(3.0)");
source = source
.replace(/style:\$\(a\.sidebarContent,\{rowGap:o\.sectionGap\}\)/g, "style:$(a.sidebarContent,{rowGap:o.gapY(3.0)})")
.replace(/style:\$\(a\.mainContent,\{rowGap:o\.sectionGap\}\)/g, "style:$(a.mainContent,{rowGap:o.gapY(3.0)})");
source = source.replace(
/sectionHeading:\{borderBottomWidth:1,borderBottomColor:a(?:,paddingBottom:1(?:\.3)?)?\}/,
"sectionHeading:{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1.3}",
);
source = source.replace(
/sectionHeading:\{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1(?:\.3)?\},item:\{rowGap:([a-zA-Z_$][\w$]*)\.gapY\(\.125\)\}/,
"sectionHeading:{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1.3},sectionItems:{paddingTop:$1.gapY(.55)},item:{rowGap:$1.gapY(.2)}",
);
source = replaceOnce(
source,
"sidebarColumn:{zIndex:1,backgroundColor:o,paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,rowGap:c.sectionGap}",
"sidebarColumn:{zIndex:1,backgroundColor:o,paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,paddingBottom:c.page.paddingVertical,rowGap:c.gapY(3.0)}",
"public Glalie sidebar bottom padding",
);
source = replaceOnce(
source,
"mainContent:{paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical}",
"mainContent:{paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,paddingBottom:c.page.paddingVertical}",
"public Glalie main bottom padding",
);
return source
.replace(/([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\*\.(?:2|08);return\{paragraph:\{marginTop:\1,marginBottom:\1\},listItem:\{marginTop:\1,marginBottom:\1\}\}/,
"$1=$2*.08;return{paragraph:{marginTop:$1,marginBottom:$1},listItem:{marginTop:$1,marginBottom:$1}}")
.replace(/o\.gapY\(3\.5\)/g, "o.gapY(3.0)")
.replace(/c\.gapY\(3\.5\)/g, "c.gapY(3.0)")
.replace(/o\.gapY\(2\.6\)/g, "o.gapY(3.0)")
.replace(/c\.gapY\(2\.6\)/g, "c.gapY(3.0)")
.replace(/o\.gapY\(2\.2\)/g, "o.gapY(3.0)")
.replace(/c\.gapY\(2\.2\)/g, "c.gapY(3.0)");
}
function patchImporters() {
const assetsDir = "/app/apps/web/.output/public/assets";
const files = fs
.readdirSync(assetsDir)
.filter((name) => name.endsWith(".js"))
.map((name) => `${assetsDir}/${name}`)
.filter((file) => fs.readFileSync(file, "utf8").includes("pdf-document-BplbXx-0.js"));
for (const file of files) {
let source = fs.readFileSync(file, "utf8");
source = source.replace(/\.\/pdf-document-BplbXx-0\.js(?:\?v=rr-[^"'`]+)?/g, `./pdf-document-BplbXx-0.js?v=${cacheBust}`);
fs.writeFileSync(file, source);
}
return files;
}
function makeEtag(buffer) {
const digest = crypto.createHash("sha1").update(buffer).digest("base64").replace(/=+$/g, "");
return `"${buffer.length.toString(16)}-${digest}"`;
}
function patchStaticManifestEntry(source, urlPath, filePath) {
const buffer = fs.readFileSync(filePath);
const startMarker = `"${urlPath}": {`;
const start = source.indexOf(startMarker);
if (start === -1) throw new Error(`Static manifest entry not found for ${urlPath}`);
const commaEnd = source.indexOf("\n\t},", start);
const objectEnd = source.indexOf("\n\t}", start);
const end = commaEnd === -1 ? objectEnd : Math.min(commaEnd, objectEnd);
if (end === -1) throw new Error(`Static manifest entry end not found for ${urlPath}`);
let entry = source.slice(start, end);
entry = entry
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
.replace(/"size": \d+/, `"size": ${buffer.length}`);
return source.slice(0, start) + entry + source.slice(end);
}
fs.writeFileSync(ssrFile, patchSsr(fs.readFileSync(ssrFile, "utf8")));
fs.writeFileSync(publicFile, patchPublic(fs.readFileSync(publicFile, "utf8")));
const importers = patchImporters();
let serverIndex = fs.readFileSync(serverIndexFile, "utf8");
serverIndex = patchStaticManifestEntry(serverIndex, "/assets/pdf-document-BplbXx-0.js", publicFile);
for (const file of importers) {
const urlPath = `/assets/${file.split("/").pop()}`;
serverIndex = patchStaticManifestEntry(serverIndex, urlPath, file);
}
fs.writeFileSync(serverIndexFile, serverIndex);
NODE
node --check "$SSR_FILE" >/dev/null
node --check "$PUBLIC_FILE" >/dev/null
node --check "$SERVER_INDEX_FILE" >/dev/null
SH
# Nitro loads the static asset manifest into memory at process startup. Restart
# after patching so updated content-length/etag values are used immediately.
docker restart "$CONTAINER" >/dev/null
for _ in $(seq 1 60); do
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$CONTAINER")"
[ "$health" = "healthy" ] && exit 0
sleep 2
done
docker logs --tail 80 "$CONTAINER" >&2
exit 1

View File

@@ -0,0 +1,161 @@
#!/bin/sh
set -eu
CONTAINER="${1:-reactive-resume-reactive-resume-1}"
docker exec -u root -i "$CONTAINER" sh <<'SH'
set -eu
SSR_FILE="/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs"
SW_FILE="/app/apps/web/.output/public/sw.js"
SERVER_INDEX_FILE="/app/apps/web/.output/server/index.mjs"
SSR_RENDERER_FILE="/app/apps/web/.output/server/_chunks/ssr-renderer.mjs"
test -f "$SSR_FILE.bak-sw-cache" || cp "$SSR_FILE" "$SSR_FILE.bak-sw-cache" 2>/dev/null || true
test -f "$SW_FILE.bak-sw-cache" || cp "$SW_FILE" "$SW_FILE.bak-sw-cache" 2>/dev/null || true
test -f "$SERVER_INDEX_FILE.bak-sw-cache" || cp "$SERVER_INDEX_FILE" "$SERVER_INDEX_FILE.bak-sw-cache" 2>/dev/null || true
test -f "$SSR_RENDERER_FILE.bak-sw-cache" || cp "$SSR_RENDERER_FILE" "$SSR_RENDERER_FILE.bak-sw-cache" 2>/dev/null || true
node - <<'NODE'
const fs = require("fs");
const crypto = require("crypto");
const ssrFile = "/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs";
const swFile = "/app/apps/web/.output/public/sw.js";
const serverIndexFile = "/app/apps/web/.output/server/index.mjs";
const ssrRendererFile = "/app/apps/web/.output/server/_chunks/ssr-renderer.mjs";
const registrationScript = `
\t(() => {
\t\tif (!("serviceWorker" in navigator)) return;
\t\twindow.addEventListener("load", () => {
\t\t\tconst clearReactiveResumeCaches = async () => {
\t\t\t\tif ("caches" in window) {
\t\t\t\t\tconst keys = await caches.keys();
\t\t\t\t\tawait Promise.all(keys.map((key) => caches.delete(key)));
\t\t\t\t}
\t\t\t\tif (navigator.serviceWorker.getRegistrations) {
\t\t\t\t\tconst registrations = await navigator.serviceWorker.getRegistrations();
\t\t\t\t\tawait Promise.all(registrations.map((registration) => registration.unregister()));
\t\t\t\t}
\t\t\t};
\t\t\tclearReactiveResumeCaches().catch(console.error);
\t\t});
\t})();
`;
let ssr = fs.readFileSync(ssrFile, "utf8");
const start = "var pwaServiceWorkerRegistrationScript = `";
const end = "`;\nvar src_default =";
const startIndex = ssr.indexOf(start);
if (startIndex === -1) {
throw new Error("Service worker registration script start marker not found");
}
const endIndex = ssr.indexOf(end, startIndex + start.length);
if (endIndex === -1) {
throw new Error("Service worker registration script end marker not found");
}
ssr =
ssr.slice(0, startIndex) +
start +
registrationScript +
ssr.slice(endIndex);
fs.writeFileSync(ssrFile, ssr);
const sw = `self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
await self.registration.unregister();
await self.clients.claim();
const clients = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
for (const client of clients) {
client.postMessage({ type: "RR_SW_CACHE_CLEARED" });
}
})());
});
self.addEventListener("fetch", () => {});
`;
fs.writeFileSync(swFile, sw);
function makeEtag(buffer) {
const digest = crypto.createHash("sha1").update(buffer).digest("base64").replace(/=+$/g, "");
return `"${buffer.length.toString(16)}-${digest}"`;
}
function patchStaticManifestEntry(source, urlPath, filePath) {
const buffer = fs.readFileSync(filePath);
const startMarker = `"${urlPath}": {`;
const start = source.indexOf(startMarker);
if (start === -1) {
throw new Error(`Static manifest entry not found for ${urlPath}`);
}
const end = source.indexOf("\n\t},", start);
if (end === -1) {
throw new Error(`Static manifest entry end not found for ${urlPath}`);
}
let entry = source.slice(start, end);
entry = entry
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
.replace(/"size": \d+/, `"size": ${buffer.length}`);
return source.slice(0, start) + entry + source.slice(end);
}
let serverIndex = fs.readFileSync(serverIndexFile, "utf8");
serverIndex = patchStaticManifestEntry(serverIndex, "/sw.js", swFile);
fs.writeFileSync(serverIndexFile, serverIndex);
let ssrRenderer = fs.readFileSync(ssrRendererFile, "utf8");
const ssrRendererOriginal = `function ssrRenderer({ req }) {
\treturn fetchViteEnv("ssr", req);
}`;
const ssrRendererPatched = `async function ssrRenderer(event) {
\tconst response = await fetchViteEnv("ssr", event.req);
\tconst headers = new Headers(response.headers);
\tconst accept = event.req.headers.get("accept") || "";
\tif (accept.includes("text/html")) {
\t\theaders.set("Cache-Control", "no-store, max-age=0");
\t\theaders.set("Pragma", "no-cache");
\t\theaders.set("Expires", "0");
\t}
\treturn new Response(response.body, {
\t\tstatus: response.status,
\t\tstatusText: response.statusText,
\t\theaders,
\t});
}`;
if (!ssrRenderer.includes(ssrRendererPatched)) {
if (!ssrRenderer.includes(ssrRendererOriginal)) {
throw new Error("SSR renderer marker not found");
}
ssrRenderer = ssrRenderer.replace(ssrRendererOriginal, ssrRendererPatched);
fs.writeFileSync(ssrRendererFile, ssrRenderer);
}
NODE
node --check "$SSR_FILE" >/dev/null
node --check "$SW_FILE" >/dev/null
node --check "$SERVER_INDEX_FILE" >/dev/null
node --check "$SSR_RENDERER_FILE" >/dev/null
SH

View File

@@ -0,0 +1,338 @@
#!/bin/sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
DIRECT_COMPOSE="$ROOT_DIR/packages/reactive-resume-personal-direct/compose.yml"
DIRECT_ENV="$ROOT_DIR/packages/reactive-resume-personal-direct/.env"
QNAP_COMPOSE="$ROOT_DIR/packages/reactive-resume-personal-qnap-nas/compose-Nas.yml"
QNAP_PATCH_DIR="$ROOT_DIR/packages/reactive-resume-personal-qnap-nas/patches"
DIRECT_PATCH_DIR="$ROOT_DIR/packages/reactive-resume-personal-direct/patches"
QNAP_ZIP="$ROOT_DIR/dist/reactive-resume-personal-qnap-nas-20260520.zip"
DIRECT_ZIP="$ROOT_DIR/dist/reactive-resume-personal-direct-20260520.zip"
IMAGE_REPO="amruthpillai/reactive-resume"
IMAGE_INDEX="$IMAGE_REPO@sha256:b760446c4301af067e7d595537a877e378363aa6ce921b7349e62983621826aa"
PROJECT="reactive-resume-personal"
log() {
printf '\n[TEST] %s\n' "$*"
}
fail() {
printf '\n[FAIL] %s\n' "$*" >&2
exit 1
}
cleanup_direct() {
docker compose -f "$DIRECT_COMPOSE" --env-file "$DIRECT_ENV" down -v >/dev/null 2>&1 || true
}
cleanup_tmp() {
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
}
cleanup_all() {
cleanup_direct
cleanup_tmp
}
trap cleanup_all HUP INT TERM EXIT
cd "$ROOT_DIR"
log "检查补丁脚本语法"
sh -n "$QNAP_PATCH_DIR/reactive-resume-runtime-patch.sh"
sh -n "$QNAP_PATCH_DIR/reactive-resume-entrypoint.sh"
sh -n "$DIRECT_PATCH_DIR/reactive-resume-runtime-patch.sh"
sh -n "$DIRECT_PATCH_DIR/reactive-resume-entrypoint.sh"
log "检查 Compose 配置可解析"
docker compose -f "$QNAP_COMPOSE" config >/tmp/reactive-resume-qnap-compose-test.yml
docker compose -f "$DIRECT_COMPOSE" --env-file "$DIRECT_ENV" config >/tmp/reactive-resume-direct-compose-test.yml
grep -q 'reactive-resume-entrypoint.sh' /tmp/reactive-resume-qnap-compose-test.yml
grep -q 'reactive-resume-entrypoint.sh' /tmp/reactive-resume-direct-compose-test.yml
log "检查 zip 安装包内容"
unzip -t "$QNAP_ZIP" >/dev/null
unzip -t "$DIRECT_ZIP" >/dev/null
unzip -l "$QNAP_ZIP" | grep -q 'reactive_resume/compose-Nas.yml'
unzip -l "$QNAP_ZIP" | grep -q 'reactive_resume/patches/reactive-resume-entrypoint.sh'
unzip -l "$QNAP_ZIP" | grep -q 'reactive_resume/patches/reactive-resume-runtime-patch.sh'
unzip -l "$DIRECT_ZIP" | grep -q 'reactive-resume-personal-direct/compose.yml'
unzip -l "$DIRECT_ZIP" | grep -q 'reactive-resume-personal-direct/patches/reactive-resume-entrypoint.sh'
if unzip -p "$QNAP_ZIP" 'reactive_resume/*' 2>/dev/null | grep -E 'isiseg|10004|Reactive_Resume_Personal|/share/Container/Reactive_Resume_Personal' >/dev/null; then
fail "QNAP zip 中仍有旧域名、旧端口或旧路径"
fi
if unzip -p "$QNAP_ZIP" 'reactive_resume/*' 2>/dev/null | grep -E 'amruthpillai/reactive-resume:latest|snowdreamtech/frpc:latest' >/dev/null; then
fail "QNAP zip 中仍有 latest 镜像"
fi
if unzip -p "$DIRECT_ZIP" 'reactive-resume-personal-direct/*' 2>/dev/null | grep -E 'amruthpillai/reactive-resume:latest|snowdreamtech/frpc:latest' >/dev/null; then
fail "direct zip 中仍有 latest 镜像"
fi
log "真实启动 direct 包并检查健康状态"
cleanup_direct
docker compose -f "$DIRECT_COMPOSE" --env-file "$DIRECT_ENV" up -d postgres reactive-resume seed
attempt=0
until curl -fsS "http://127.0.0.1:3004/api/health" >/tmp/reactive-resume-health.json 2>/dev/null; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 60 ]; then
docker logs "$PROJECT-reactive-resume-1" --tail 200 >&2 || true
fail "direct 包启动后 /api/health 未在 60 秒内就绪"
fi
sleep 1
done
docker wait "$PROJECT-seed-1" >/tmp/reactive-resume-seed-exit
seed_status="$(docker inspect "$PROJECT-seed-1" --format '{{.State.ExitCode}}' 2>/dev/null || printf 'missing')"
[ "$seed_status" = "0" ] || fail "seed 容器退出码不是 0$seed_status"
attempt=0
until curl -fsS -I "http://127.0.0.1:3004/audience/resume" >/tmp/reactive-resume-audience.headers 2>/dev/null \
&& grep -q '200 OK' /tmp/reactive-resume-audience.headers; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 30 ]; then
docker logs "$PROJECT-reactive-resume-1" --tail 200 >&2 || true
fail "seed 完成后 /audience/resume 未在 30 秒内返回 200"
fi
sleep 1
done
docker logs "$PROJECT-reactive-resume-1" --tail 200 >/tmp/reactive-resume-direct.log 2>&1 || true
if grep -E 'Cannot find module|Buffer is not defined|Unexpected end of input' /tmp/reactive-resume-direct.log >/dev/null; then
cat /tmp/reactive-resume-direct.log >&2
fail "direct 包日志仍包含已知启动或前端错误"
fi
if docker exec "$PROJECT-reactive-resume-1" sh -lc 'APP_DIR=$(cat /tmp/reactive-resume-app-dir); grep -R -E "index-[A-Za-z0-9_-]+\\.js\\?v=rr-filename-title" "$APP_DIR/.output/public/assets" >/dev/null 2>&1'; then
fail "direct 包错误地给 index 主入口追加了 rr-filename-title 缓存标记"
fi
docker exec "$PROJECT-reactive-resume-1" sh -lc 'APP_DIR=$(cat /tmp/reactive-resume-app-dir); grep -R "String(t).trim().replace" "$APP_DIR/.output/public/assets"/file-*.js >/dev/null 2>&1' \
|| fail "direct 包未在 file-*.js 下载工具中应用文件名补丁"
curl -fsS "http://127.0.0.1:3004/" >/tmp/reactive-resume-home.html \
|| fail "direct 包首页无法访问"
grep -E '/assets/index-[A-Za-z0-9_-]+-rr[0-9a-z]+\.js' /tmp/reactive-resume-home.html >/dev/null \
|| fail "direct 包首页未改用防缓存的 index 主入口文件名"
curl -fsS "http://127.0.0.1:3004/sw.js" >/tmp/reactive-resume-sw.js \
|| fail "direct 包 sw.js 无法访问"
grep -q 'disable stale PWA caches' /tmp/reactive-resume-sw.js \
|| fail "direct 包 sw.js 未替换为清理旧缓存版本"
if command -v google-chrome >/dev/null 2>&1 && command -v node >/dev/null 2>&1; then
log "使用 Chrome 执行 direct 包首页 JS"
node <<'NODE'
const { spawn } = require("child_process");
const fs = require("fs");
const http = require("http");
const port = 9444;
const userData = "/tmp/reactive-resume-chrome-smoke";
fs.rmSync(userData, { recursive: true, force: true });
const chrome = spawn("google-chrome", [
"--headless=new",
"--disable-gpu",
"--no-sandbox",
`--remote-debugging-port=${port}`,
`--user-data-dir=${userData}`,
"about:blank",
], { stdio: ["ignore", "ignore", "ignore"] });
function request(path, method = "GET") {
return new Promise((resolve, reject) => {
const req = http.request({ host: "127.0.0.1", port, path, method }, (res) => {
let body = "";
res.on("data", (chunk) => body += chunk);
res.on("end", () => resolve({ status: res.statusCode, body }));
});
req.on("error", reject);
req.end();
});
}
async function waitForChrome() {
for (let i = 0; i < 60; i++) {
try {
const res = await request("/json/version");
if (res.status === 200) return;
} catch {}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error("Chrome remote debugging was not ready");
}
(async () => {
await waitForChrome();
const tab = JSON.parse((await request("/json/new?about:blank", "PUT")).body);
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0;
const pending = new Map();
const errors = [];
const indexRequests = [];
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.id && pending.has(message.id)) {
pending.get(message.id)(message);
pending.delete(message.id);
return;
}
if (message.method === "Runtime.exceptionThrown") {
errors.push(message.params.exceptionDetails);
}
if (message.method === "Network.requestWillBeSent" && message.params.request.url.includes("index-")) {
indexRequests.push(message.params.request.url);
}
};
await new Promise((resolve) => { ws.onopen = resolve; });
function send(method, params = {}) {
return new Promise((resolve) => {
pending.set(++id, resolve);
ws.send(JSON.stringify({ id, method, params }));
});
}
await send("Runtime.enable");
await send("Page.enable");
await send("Network.enable");
await send("Page.navigate", { url: "http://127.0.0.1:3004/" });
await new Promise((resolve) => setTimeout(resolve, 5000));
const result = await send("Runtime.evaluate", {
expression: "document.body.innerText.includes('A free and open-source resume builder')",
returnByValue: true,
});
ws.close();
chrome.kill("SIGKILL");
if (errors.length > 0) {
console.error(JSON.stringify(errors.map((error) => ({
text: error.text,
url: error.url,
line: error.lineNumber,
column: error.columnNumber,
exception: error.exception?.description,
})), null, 2));
process.exit(1);
}
if (!result.result?.result?.value) {
console.error("Chrome did not render the expected home page text");
process.exit(1);
}
if (indexRequests.some((url) => /\/assets\/index-[A-Za-z0-9_-]+\.js$/.test(url) && !/-rr[0-9a-z]+\.js$/.test(url))) {
console.error(`Chrome still requested stale app shell: ${indexRequests.join(", ")}`);
process.exit(1);
}
})().catch((error) => {
chrome.kill("SIGKILL");
console.error(error);
process.exit(1);
});
NODE
else
log "未找到 google-chrome跳过浏览器级首页 JS 测试"
fi
log "离线检查 arm64/QNAP 镜像布局"
ARM64_DIGEST="$(
docker manifest inspect "$IMAGE_INDEX" \
| node -e '
let source = "";
process.stdin.on("data", (chunk) => source += chunk);
process.stdin.on("end", () => {
const manifest = JSON.parse(source);
const arm = manifest.manifests.find((item) => item.platform?.os === "linux" && item.platform?.architecture === "arm64");
if (!arm) process.exit(2);
process.stdout.write(arm.digest);
});
'
)"
TMP_DIR="$(mktemp -d)"
CID="$(docker create --platform linux/arm64 "$IMAGE_INDEX" 2>/dev/null || true)"
[ -n "$CID" ] || fail "无法创建 arm64 镜像容器用于离线检查"
docker export "$CID" -o "$TMP_DIR/arm64-root.tar"
docker rm "$CID" >/dev/null
mkdir -p "$TMP_DIR/arm64-root"
tar -xf "$TMP_DIR/arm64-root.tar" -C "$TMP_DIR/arm64-root"
if [ -f "$TMP_DIR/arm64-root/app/apps/server/dist/index.mjs" ]; then
ARM64_SERVER_ENTRY="$TMP_DIR/arm64-root/app/apps/server/dist/index.mjs"
ARM64_FILENAME_ENTRY="$ARM64_SERVER_ENTRY"
ARM64_ASSETS_DIR="$TMP_DIR/arm64-root/app/apps/web/dist/assets"
EXPECTED_ENTRYPOINT_PWD="$TMP_DIR/arm64-root/app"
EXPECTED_ENTRYPOINT_ARGS="node apps/server/dist/index.mjs"
elif [ -f "$TMP_DIR/arm64-root/app/apps/web/.output/server/index.mjs" ]; then
ARM64_SERVER_ENTRY="$TMP_DIR/arm64-root/app/apps/web/.output/server/index.mjs"
ARM64_FILENAME_ENTRY="$(grep -Rsl 'function generateFilename' "$TMP_DIR/arm64-root/app/apps/web/.output/server/_ssr" 2>/dev/null | head -n 1 || true)"
[ -n "$ARM64_FILENAME_ENTRY" ] || fail "arm64 .output 布局中未找到 generateFilename SSR bundle"
ARM64_ASSETS_DIR="$TMP_DIR/arm64-root/app/apps/web/.output/public/assets"
EXPECTED_ENTRYPOINT_PWD="$TMP_DIR/arm64-root/app/apps/web"
EXPECTED_ENTRYPOINT_ARGS="node .output/server/index.mjs"
else
find "$TMP_DIR/arm64-root/app" -maxdepth 6 \( -name index.mjs -o -name server.js -o -name main.js \) 2>/dev/null >&2 || true
fail "arm64 镜像中未找到支持的服务入口"
fi
[ -d "$ARM64_ASSETS_DIR" ] || fail "arm64 镜像中未找到 assets 目录:$ARM64_ASSETS_DIR"
perl -0pe "
s#/app/apps#$TMP_DIR/arm64-root/app/apps#g;
s#for candidate in $TMP_DIR/arm64-root/app/apps/web /app#for candidate in $TMP_DIR/arm64-root/app/apps/web $TMP_DIR/arm64-root/app#g;
s#find /app#find $TMP_DIR/arm64-root/app#g;
s#APP_DIR=\"/app\"#APP_DIR=\"$TMP_DIR/arm64-root/app\"#g;
s#under /app#under $TMP_DIR/arm64-root/app#g;
" "$QNAP_PATCH_DIR/reactive-resume-runtime-patch.sh" > "$TMP_DIR/runtime-patch-arm64-test.sh"
sh "$TMP_DIR/runtime-patch-arm64-test.sh" >/tmp/reactive-resume-arm64-runtime.log 2>&1 || {
cat /tmp/reactive-resume-arm64-runtime.log >&2
fail "arm64 离线运行 runtime patch 失败"
}
grep -R 'rr-browser-buffer-polyfill' "$ARM64_ASSETS_DIR" >/dev/null \
|| fail "arm64 public PDF bundle 未注入 Buffer polyfill"
grep -R -F 'replace(/[\\/:*?"<>|]/g' "$ARM64_ASSETS_DIR" >/dev/null \
|| fail "arm64 文件名 bundle 未改为按标题下载"
if grep -R -E "index-[A-Za-z0-9_-]+\\.js\\?v=rr-filename-title" "$ARM64_ASSETS_DIR" >/dev/null 2>&1; then
fail "arm64 补丁错误地给 index 主入口追加了 rr-filename-title 缓存标记"
fi
find "$TMP_DIR/arm64-root/app" -type f -exec grep -qE 'index-[A-Za-z0-9_-]+-rr[0-9a-z]+\.js' {} \; -print -quit \
| grep -q . \
|| fail "arm64 补丁未改用防缓存的 index 主入口文件名"
find "$TMP_DIR/arm64-root/app" -path '*/sw.js' -type f -print0 | xargs -0 grep -l 'disable stale PWA caches' >/dev/null \
|| fail "arm64 sw.js 未替换为清理旧缓存版本"
grep -q 'function generateFilename(prefix, extension)' "$ARM64_FILENAME_ENTRY" \
|| fail "arm64 server entry 未包含 generateFilename"
grep -F 'filename.replace(/[\\/:*?"<>|]/g' "$ARM64_FILENAME_ENTRY" >/dev/null \
|| fail "arm64 server entry 未改为按标题生成下载文件名"
perl -0pe "
s#/app/apps#$TMP_DIR/arm64-root/app/apps#g;
s#cd /app#cd $TMP_DIR/arm64-root/app#g;
s#find /app#find $TMP_DIR/arm64-root/app#g;
s#under /app#under $TMP_DIR/arm64-root/app#g;
" "$QNAP_PATCH_DIR/reactive-resume-entrypoint.sh" > "$TMP_DIR/entrypoint-arm64-test.sh"
mkdir -p "$TMP_DIR/fakebin"
{
printf '#!/bin/sh\n'
printf 'printf "PWD=%%s\\n" "$PWD" > "%s/entrypoint-result.txt"\n' "$TMP_DIR"
printf 'printf "ARGS=%%s\\n" "$*" >> "%s/entrypoint-result.txt"\n' "$TMP_DIR"
} > "$TMP_DIR/fakebin/docker-entrypoint.sh"
chmod +x "$TMP_DIR/fakebin/docker-entrypoint.sh"
PATH="$TMP_DIR/fakebin:$PATH" sh "$TMP_DIR/entrypoint-arm64-test.sh" >/tmp/reactive-resume-arm64-entrypoint.log 2>&1 || {
cat /tmp/reactive-resume-arm64-entrypoint.log >&2
fail "arm64 entrypoint 选择测试失败"
}
grep -q "PWD=$EXPECTED_ENTRYPOINT_PWD" "$TMP_DIR/entrypoint-result.txt" \
|| fail "arm64 entrypoint 未切换到预期目录:$EXPECTED_ENTRYPOINT_PWD"
grep -q "ARGS=$EXPECTED_ENTRYPOINT_ARGS" "$TMP_DIR/entrypoint-result.txt" \
|| fail "arm64 entrypoint 未选择预期入口:$EXPECTED_ENTRYPOINT_ARGS"
log "清理 direct 测试容器"
cleanup_direct
log "全部测试通过"
printf 'direct health: %s\n' "$(cat /tmp/reactive-resume-health.json)"
printf 'arm64 digest: %s\n' "$ARM64_DIGEST"

View File

@@ -0,0 +1,45 @@
# 实现方案-2026-05-19-23-10-56
## 实现方案文档路径
`工程分析/实现方案-2026-05-19-23-10-56.md`
## 修改目标
将 Reactive Resume PDF 下载文件名从 `ZhiboWang-Resume.pdf` 改为 `王志博-医工智能外科-简历.pdf`
## 涉及路径
- `scripts/patch-reactive-resume-filename.sh`
- `工程分析/工程整体分析.md`
- `工程分析/需求分析-2026-05-19-23-10-56.md`
- `工程分析/实现方案-2026-05-19-23-10-56.md`
- `工程分析/测试方案-2026-05-19-23-10-56.md`
- `工程分析/经验记录.md`
## 技术路线
1. 修改补丁脚本中的 shell 变量 `PDF_FILENAME`
2. 修改脚本中 Node 补丁逻辑的 `pdfFilename` 常量。
3. 更新 `CACHE_BUST`,并让静态资源 import 替换逻辑兼容旧 cache bust。
4.`sh -n` 检查脚本语法。
5. 执行 `scripts/patch-reactive-resume-filename.sh reactive-resume-reactive-resume-1`
6. 验证容器健康、文件内新文件名、HTTP 静态资源新文件名。
7. 提交本次脚本和文档变更。
## 兼容性与回滚方案
- 回滚时可把 `PDF_FILENAME``pdfFilename``CACHE_BUST` 改回旧值,再重新执行脚本。
- 若补丁失败,容器内已有 `.bak-filename` 备份可用于人工恢复。
- 本次不改数据库、简历 JSON 和 Docker Compose 配置。
## 预计文件变更
- 更新:`scripts/patch-reactive-resume-filename.sh`
- 新增/更新:`工程分析/` 本次文档与经验记录。
## 提交与部署策略
- 显式暂存本次相关文件,避免混入无关变更。
- commit message 使用:`2026-05-19-23-10-56 修改简历PDF下载文件名`
- 部署通过运行补丁脚本完成,脚本内部会重启 Reactive Resume 容器。

View File

@@ -0,0 +1,44 @@
# 实现方案-2026-05-19-23-23-50
## 实现方案文档路径
`工程分析/实现方案-2026-05-19-23-23-50.md`
## 修改目标
回滚上一条错误指令,将 PDF 下载文件名恢复为 `ZhiboWang-Resume.pdf`
## 涉及路径
- `scripts/patch-reactive-resume-filename.sh`
- `工程分析/需求分析-2026-05-19-23-23-50.md`
- `工程分析/实现方案-2026-05-19-23-23-50.md`
- `工程分析/测试方案-2026-05-19-23-23-50.md`
- `工程分析/经验记录.md`
## 技术路线
1. 将脚本中的 `PDF_FILENAME` 恢复为 `ZhiboWang-Resume.pdf`
2. 将 Node 补丁逻辑中的 `pdfFilename` 恢复为 `ZhiboWang-Resume.pdf`
3.`CACHE_BUST``cacheBust` 恢复为 `rr-filename-20260519`
4. 执行 `sh -n` 做脚本语法检查。
5. 重新执行补丁脚本,更新运行中容器并等待 healthy。
6. 验证 HTTP 静态资源中包含旧文件名且不再包含中文文件名。
7. 创建回滚 commit并尝试推送 Gitea。
## 兼容性与回滚方案
- 本次不改数据库、简历 JSON、Compose 或上传文件。
- 如用户后续给出新的正确文件名,可再次改脚本变量并执行补丁脚本。
- 当前选择新增回滚提交,保留历史可追溯性。
## 预计文件变更
- 更新:`scripts/patch-reactive-resume-filename.sh`
- 新增:本次 `需求分析``实现方案``测试方案`
- 更新:`工程分析/经验记录.md`
## 提交与部署策略
- commit message 使用:`2026-05-19-23-23-50 回滚简历PDF下载文件名`
- 部署通过执行 `scripts/patch-reactive-resume-filename.sh reactive-resume-reactive-resume-1` 完成。

View File

@@ -0,0 +1,32 @@
# 工程整体分析
更新时间2026-05-19-23-10-56
## 项目定位
本项目用于部署和维护个人 Reactive Resume 服务,服务公网访问地址为 `https://me.huijutec.cn`,公开简历入口为 `/audience/resume`
## 当前结构
- `README.md`:部署、访问、安装包说明。
- `compose.yml`:本机 Docker Compose 部署,包含 Postgres、Reactive Resume、frpc。
- `compose-Nas.yml`QNAP/NAS 部署版本。
- `scripts/patch-reactive-resume-filename.sh`:对运行中 Reactive Resume 容器打补丁,控制 PDF 下载文件名,并修正静态资源 cache bust 与 Nitro manifest。
- `scripts/patch-reactive-resume-glalie-layout.sh`:简历布局补丁。
- `scripts/patch-reactive-resume-service-worker-cache.sh`Service Worker 缓存补丁。
- `packages/`:安装包目录。
- `dist/`:发布归档。
- `生成简历/`:简历源数据、备份、生成 PDF 和展示素材。
## 当前运行环境
- Docker 容器 `reactive-resume-reactive-resume-1` 运行 Reactive Resume映射 `127.0.0.1:3003 -> 3000`
- 容器 `reactive-resume-frpc-1` 负责公网映射。
- `origin` 远端为 Gitea HTTP 地址:`http://192.168.31.5:5002/admin/Reactive_Resume.git`
## 维护注意事项
- 下载文件名由容器内打补丁脚本控制,不是普通业务源码直接控制。
- 修改 PDF 文件名后需要更新 cache bust防止浏览器继续使用旧静态资源。
- 补丁脚本会修改容器内 `/app/apps/web/.output` 文件并重启容器,执行后必须等待健康检查通过。
- Gitea HTTP 远端可能缺少交互式凭据,推送失败时应保留本地 commit不要在命令行写入账号密码。

View File

@@ -0,0 +1,44 @@
# 测试方案-2026-05-19-23-10-56
## 测试方案文档路径
`工程分析/测试方案-2026-05-19-23-10-56.md`
## 静态检查
- 执行 `sh -n scripts/patch-reactive-resume-filename.sh`
## 部署验证
- 执行 `scripts/patch-reactive-resume-filename.sh reactive-resume-reactive-resume-1`
- 确认容器 `reactive-resume-reactive-resume-1` 恢复 healthy。
## 业务验证
- 在容器内搜索 `王志博-医工智能外科-简历.pdf`
- 通过 `curl http://127.0.0.1:3003/assets/file-D5WsIgJH.js` 验证静态资源已包含新文件名。
- 通过 `curl http://127.0.0.1:3003/api/health` 验证服务健康。
## Git/Gitea 备份验证
- 本地创建包含时间戳和简要描述的 commit。
- 尝试 `git push origin main`
- 若 Gitea HTTP 凭据不可用,记录失败原因并保留本地 commit。
## 风险与回归关注点
- 浏览器缓存可能保留旧静态资源,因此本次必须更新 cache bust。
- Reactive Resume 镜像升级后构建文件哈希可能变化,脚本内固定文件路径需要重新定位。
## 实际执行结果
- `sh -n scripts/patch-reactive-resume-filename.sh`:通过。
- `./scripts/patch-reactive-resume-filename.sh reactive-resume-reactive-resume-1`:通过。
- 容器健康状态:`reactive-resume-reactive-resume-1``healthy`
- 容器内验证:`/app/apps/web/.output/public/assets/file-D5WsIgJH.js``/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs` 均包含 `王志博-医工智能外科-简历.pdf`
- HTTP 静态资源验证:`curl http://127.0.0.1:3003/assets/file-D5WsIgJH.js` 可检索到 `王志博-医工智能外科-简历.pdf`
- Cache bust 验证:业务页面 import 已指向 `file-D5WsIgJH.js?v=rr-filename-20260519-cn`
- 服务健康验证:`curl http://127.0.0.1:3003/api/health` 返回 `status: healthy`
- 公开简历页验证:`curl -I http://127.0.0.1:3003/audience/resume` 返回 `HTTP/1.1 200 OK`
- Git 本地备份 commit已创建提交信息为 `2026-05-19-23-10-56 修改简历PDF下载文件名`
- Gitea 远端推送:执行 `git push origin main` 时失败,原因是 HTTP 远端 `http://192.168.31.5:5002` 无法读取用户名;未在命令行写入账号密码。

View File

@@ -0,0 +1,48 @@
# 测试方案-2026-05-19-23-23-50
## 测试方案文档路径
`工程分析/测试方案-2026-05-19-23-23-50.md`
## 静态检查
- 执行 `sh -n scripts/patch-reactive-resume-filename.sh`
- 搜索脚本确认包含 `ZhiboWang-Resume.pdf`
## 部署验证
- 执行 `./scripts/patch-reactive-resume-filename.sh reactive-resume-reactive-resume-1`
- 确认容器 `reactive-resume-reactive-resume-1``healthy`
## 业务验证
- 验证容器内 public JS 与 SSR 文件包含 `ZhiboWang-Resume.pdf`
- 验证 HTTP 静态资源 `/assets/file-D5WsIgJH.js` 包含 `ZhiboWang-Resume.pdf`
- 验证 HTTP 静态资源不再包含 `王志博-医工智能外科简历.pdf``王志博-医工智能外科-简历.pdf`
- 验证 `http://127.0.0.1:3003/api/health` 返回 healthy。
- 验证 `http://127.0.0.1:3003/audience/resume` 返回 200。
## Git/Gitea 备份验证
- 创建本地回滚 commit。
- 尝试 `git push origin main`
- 若 HTTP 凭据不可用,记录失败原因。
## 风险与回归关注点
- 浏览器缓存可能短暂保留旧中文 cache bust脚本恢复 `rr-filename-20260519` 后应重新更新 importer 与 manifest。
## 实际执行结果
- `sh -n scripts/patch-reactive-resume-filename.sh`:通过。
- 脚本搜索:`PDF_FILENAME``pdfFilename` 均恢复为 `ZhiboWang-Resume.pdf``CACHE_BUST``cacheBust` 均恢复为 `rr-filename-20260519`
- `./scripts/patch-reactive-resume-filename.sh reactive-resume-reactive-resume-1`:通过。
- 容器状态:`reactive-resume-reactive-resume-1``healthy`
- 容器内验证public JS 与 SSR 文件均包含 `ZhiboWang-Resume.pdf`
- 容器内中文残留验证public JS 与 SSR 文件未检出 `王志博-医工智能外科`
- HTTP 静态资源验证:`/assets/file-D5WsIgJH.js` 返回内容包含 `ZhiboWang-Resume.pdf`
- Cache bust 验证:业务页面 import 已恢复为 `file-D5WsIgJH.js?v=rr-filename-20260519`
- 服务健康验证:`curl http://127.0.0.1:3003/api/health` 返回 `status: healthy`
- 公开简历页验证:`curl -I http://127.0.0.1:3003/audience/resume` 返回 `HTTP/1.1 200 OK`
- Git 本地备份 commit已创建提交信息为 `2026-05-19-23-23-50 回滚简历PDF下载文件名`
- Gitea 远端推送:执行 `git push origin main` 时失败,原因是 HTTP 远端 `http://192.168.31.5:5002` 无法读取用户名;未在命令行写入账号密码。

View File

@@ -0,0 +1,57 @@
# 经验记录
本文件用于记录个人简历构建项目修改过程中的关键问题与解决方案。每条经验使用四段式结构。
## 2026-05-19-23-10-56 Reactive Resume PDF 下载文件名修改
A. 具体问题
用户希望浏览器下载 PDF 时的文件名从 `ZhiboWang-Resume.pdf` 改为 `王志博-医工智能外科-简历.pdf`,当前 DICOM 项目中没有该文件名,需要定位真实项目。
B. 产生问题原因
该下载文件名不是 DICOM 项目功能,而是个人简历构建项目中的 Reactive Resume 容器补丁脚本硬编码。Reactive Resume 的前端 public JS 和 SSR 文件都需要同步修改,且浏览器可能缓存旧静态资源。
C. 解决问题方案
`/home/wkmgc/Desktop/个人材料编写/个人简历构建` 中定位到 `scripts/patch-reactive-resume-filename.sh`,将 shell 与 Node 补丁逻辑中的 PDF 文件名统一改为中文文件名;同时把 cache bust 更新为 `rr-filename-20260519-cn`,并让 import 替换逻辑兼容旧 cache bust。执行脚本后容器重启并恢复 healthy。
D. 后续如何避免问题
涉及线上下载文件名时,应先全局搜索目标文件名并确认真实项目;修改容器补丁脚本后必须立即运行脚本、验证容器内 public/SSR 文件、验证 HTTP 静态资源和服务健康,避免只改脚本未生效。
## 2026-05-19-23-10-56 Reactive Resume Gitea 推送凭据缺失
A. 具体问题
本次本地备份 commit 已创建,但执行 `git push origin main` 时失败Git 提示无法读取 `http://192.168.31.5:5002` 的用户名。
B. 产生问题原因
当前 `origin` 使用 HTTP Gitea 地址,执行环境没有交互式凭据输入,也没有已配置的凭据助手。
C. 解决问题方案
保留本地 commit不把账号密码写入命令、文档或 Git remote。将推送失败写入测试方案和经验记录后续由用户配置安全凭据或改为 SSH remote 后再推送。
D. 后续如何避免问题
Gitea 推送前先检查认证方式。优先使用 SSH 或安全凭据助手;如果 HTTP remote 需要 token应通过安全环境变量或凭据管理器提供不要直接拼进 URL。
## 2026-05-19-23-23-50 回滚错误的 PDF 下载文件名指令
A. 具体问题
用户说明上一条修改 PDF 下载文件名的指令写错了,需要撤销中文文件名变更。
B. 产生问题原因
上一条需求将 Reactive Resume 的 PDF 下载文件名从 `ZhiboWang-Resume.pdf` 改为中文文件名;之后最新提交又把中文文件名调整为不带短横线版本。只回滚最新提交会仍然保留中文文件名,不能真正回到原始状态。
C. 解决问题方案
以原始文件名 `ZhiboWang-Resume.pdf` 为目标,直接恢复 `scripts/patch-reactive-resume-filename.sh` 中 shell 与 Node 两处文件名常量,并恢复 cache bust 为 `rr-filename-20260519`。随后重新执行补丁脚本,让运行中容器 public JS 与 SSR 文件同步恢复。
D. 后续如何避免问题
用户要求回滚时,先确认要回到哪一个历史状态,而不是只撤销最近一个 commit。涉及线上容器补丁时Git 文件回滚后必须重新执行补丁脚本并验证 HTTP 静态资源。

View File

@@ -0,0 +1,42 @@
# 需求分析-2026-05-19-23-10-56
## 开始时间
2026-05-19-23-10-56
## 原始需求摘要
用户要求将下载的 PDF 文件名从 `ZhiboWang-Resume.pdf` 改为 `王志博-医工智能外科-简历.pdf`
## 业务目标
- 让用户从 Reactive Resume 网页下载 PDF 时,浏览器保存文件名显示为中文简历名称。
- 保持现有简历内容、部署地址和其他导出格式不变。
## 输入与输出
- 输入:当前运行的 Reactive Resume 服务和既有补丁脚本。
- 输出:更新后的补丁脚本、运行中容器内已生效的新 PDF 下载文件名。
## 影响范围
- `scripts/patch-reactive-resume-filename.sh`
- 运行中 Docker 容器 `reactive-resume-reactive-resume-1` 的静态资源与 SSR 文件。
- 新增本次工程分析文档与经验记录。
## 关键约束
- 当前 DICOM 项目内没有该下载文件名,真实目标位于 `/home/wkmgc/Desktop/个人材料编写/个人简历构建`
- 修改后需要重新执行补丁脚本并等待容器健康。
- 不能把 Gitea 凭据写入脚本、remote URL 或命令历史。
## 风险点
- 若只改脚本不执行,线上下载文件名不会立即变化。
- 若不更新 cache bust浏览器可能继续使用旧 JS 静态资源。
- 若补丁脚本匹配不到新的构建文件名,容器补丁可能失败。
## 默认假设
- 用户说的“下载的 PDF”指当前 Reactive Resume 公开简历的 PDF 下载文件名。
- 只改 PDF 文件名,不改简历标题、数据库内容或其他导出格式。

View File

@@ -0,0 +1,41 @@
# 需求分析-2026-05-19-23-23-50
## 开始时间
2026-05-19-23-23-50
## 原始需求摘要
用户说明上一条“修改 PDF 下载文件名”的指令写错了,要求回滚。
## 业务目标
- 将 Reactive Resume PDF 下载文件名恢复到改名前的 `ZhiboWang-Resume.pdf`
- 同步恢复运行中容器的 public JS 与 SSR 下载文件名逻辑。
- 保留本次回滚记录,避免后续误以为中文文件名仍是目标状态。
## 输入与输出
- 输入:当前最新脚本、当前 Docker 容器、相关 commit `d2edebe``d77954b`
- 输出:脚本恢复旧文件名、运行中服务恢复旧下载名、回滚分析与经验记录。
## 影响范围
- `scripts/patch-reactive-resume-filename.sh`
- 运行中容器 `reactive-resume-reactive-resume-1`
- `工程分析/` 本次回滚文档与经验记录
## 关键约束
- 使用非破坏性新增提交完成回滚,不使用 `git reset --hard`
- 不删除历史审计文档,新增回滚文档说明原因。
- Gitea HTTP 远端可能仍缺少凭据,推送失败需要记录。
## 风险点
- 如果只改 Git 脚本,不重新执行脚本,线上容器仍会保持中文文件名。
- 如果只回滚最新 `d77954b`,文件名会退到 `王志博-医工智能外科-简历.pdf`,仍然不是原始文件名。
## 默认假设
- “回滚一下”指回到用户提出中文文件名前的状态,即 `ZhiboWang-Resume.pdf`

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
"email": "zub572701190@stu.xjtu.edu.cn",
"phone": "+86 139-4611-2059",
"website": {
"url": "",
"label": ""
"url": "https://me.huijutec.cn/audience/resume",
"label": "me.huijutec.cn/audience/resume"
},
"headline": "AI 医工交叉博士|智能外科与微创手术导航|多模态大模型与临床转化",
"location": "陕西西安|西安交通大学",
@@ -46,19 +46,19 @@
"title": "个人简介",
"hidden": false,
"columns": 1,
"content": "<p>西安交通大学未来技术学院医工学方向博士研究生,中共党员,少年班与人工智能试验班背景。长期面向微创外科、术中影像分析、智能导航和图文病历生成等真实临床场景,推进<strong>医学问题定义、AI 算法研发、系统工程实现、专利论文产出、竞赛验证与成果转化</strong>的闭环实践,形成兼具科研深度、工程落地和组织协同能力的医工交叉特色</p>"
"content": "<p>西安交通大学未来技术学院医工学方向博士研究生,中共党员,少年班与人工智能试验班背景。长期面向微创外科、术中影像分析、智能导航和图文病历生成等真实临床场景,推进<strong>医学问题定义、AI 算法研发、系统工程实现、专利论文产出、竞赛验证与成果转化</strong>的闭环实践,形成兼具科研深度、工程落地和组织协同能力的医工交叉特色</p>"
},
"metadata": {
"page": {
"gapX": 4.4,
"gapY": 2.6,
"gapX": 3.8,
"gapY": 0.56,
"format": "a4",
"locale": "zh-CN",
"marginX": 8.2,
"marginY": 6.4,
"marginX": 8.4,
"marginY": 8.8,
"hideIcons": false
},
"notes": "Design direction: refined clinical-tech academic profile. Glalie two-column layout, wider A4 margins, CJK-first typography, navy-blue medical technology accent, and two-page hierarchy for personal achievements and translational impact.",
"notes": "Design direction: refined clinical-tech academic profile. Glalie two-column layout with CJK-safe line height, balanced list rhythm, moderate section spacing, a continued second-page sidebar, and split volunteer/social activity evidence.",
"design": {
"level": {
"icon": "star",
@@ -71,38 +71,46 @@
}
},
"layout": {
"sidebarWidth": 29.5,
"pages": [
{
"fullWidth": false,
"main": [
"summary",
"awards",
"education",
"projects",
"experience",
"1014b66d-3de1-4bf4-903c-93e7f07c8f81",
"54972f49-3c83-4912-b429-dc659a02eda9",
"36ba1eaf-0984-4863-9b4b-691374ee27aa",
"certifications",
"752ddba0-3400-4e33-8f54-49fc3a4b57b9",
"559335bd-99c4-44c2-97b6-420b011415f7"
"1014b66d-3de1-4bf4-903c-93e7f07c8f81"
],
"sidebar": [
"f4fa59b9-34cf-41b0-b324-f07f07934fc2",
"skills",
"languages",
"802bfd76-af47-4c5d-8b04-f89a38932dcd",
"volunteer"
]
"volunteer",
"4a9c2385-2f79-4d50-99cf-6d2aaef0b6c6"
],
"fullWidth": false
},
{
"main": [
"experience",
"54972f49-3c83-4912-b429-dc659a02eda9",
"752ddba0-3400-4e33-8f54-49fc3a4b57b9",
"certifications",
"36ba1eaf-0984-4863-9b4b-691374ee27aa",
"559335bd-99c4-44c2-97b6-420b011415f7"
],
"sidebar": [],
"fullWidth": false
}
]
],
"sidebarWidth": 29.2
},
"template": "glalie",
"typography": {
"body": {
"fontSize": 6.95,
"fontSize": 6.9,
"fontFamily": "Noto Sans SC",
"lineHeight": 1.22,
"lineHeight": 1.34,
"fontWeights": [
"400",
"500",
@@ -110,9 +118,9 @@
]
},
"heading": {
"fontSize": 9.25,
"fontSize": 9.15,
"fontFamily": "Noto Serif SC",
"lineHeight": 1.18,
"lineHeight": 1.28,
"fontWeights": [
"600",
"700"
@@ -123,19 +131,6 @@
"sections": {
"awards": {
"items": [
{
"id": "d05f036d-f81e-4090-a6a1-0dd53eb2e6ba",
"date": "2023",
"title": "博士国家奖学金(证书号 BSY202303836",
"hidden": false,
"awarder": "教育部",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"description": ""
},
{
"id": "f25ead8c-57eb-4421-8d7c-494d0e7c0118",
"date": "2024",
@@ -150,8 +145,47 @@
"description": ""
},
{
"id": "3d2e410a-cc9a-46f9-98db-d8e3128ef07c",
"id": "d05f036d-f81e-4090-a6a1-0dd53eb2e6ba",
"date": "2023",
"title": "博士国家奖学金(证书号 BSY202303836",
"hidden": false,
"awarder": "教育部",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"description": ""
},
{
"id": "c81e732c-8a95-406b-adcd-f8f56db00407",
"date": "2025",
"title": "陕西省研究生创新成果展 A 类成果",
"hidden": false,
"awarder": "陕西省教育厅",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"description": ""
},
{
"id": "09cfe17f-a90d-42cd-8ca6-dbabc817782a",
"date": "2024",
"title": "陕西省研究生创新成果展 A 类成果",
"hidden": false,
"awarder": "陕西省教育厅",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"description": ""
},
{
"id": "4bb24894-f47a-4e97-a980-8b1171a3a148",
"date": "2025",
"title": "西安交通大学优秀研究生",
"hidden": false,
"awarder": "西安交通大学",
@@ -176,8 +210,8 @@
"description": ""
},
{
"id": "4bb24894-f47a-4e97-a980-8b1171a3a148",
"date": "2025",
"id": "3d2e410a-cc9a-46f9-98db-d8e3128ef07c",
"date": "2023",
"title": "西安交通大学优秀研究生",
"hidden": false,
"awarder": "西安交通大学",
@@ -189,24 +223,11 @@
"description": ""
},
{
"id": "09cfe17f-a90d-42cd-8ca6-dbabc817782a",
"date": "2024",
"title": "陕西省研究生创新成果展 A 类成果",
"hidden": false,
"awarder": "陕西省",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"description": ""
},
{
"id": "c81e732c-8a95-406b-adcd-f8f56db00407",
"id": "eb4b8f1f-65e2-4287-a114-be59b47406d8",
"date": "2025",
"title": "陕西省研究生创新成果展 A 类成果",
"title": "未来技术太湖奖学金“创业实践一等奖”",
"hidden": false,
"awarder": "陕西省",
"awarder": "西安交通大学",
"website": {
"url": "",
"label": "",
@@ -240,19 +261,6 @@
},
"description": ""
},
{
"id": "eb4b8f1f-65e2-4287-a114-be59b47406d8",
"date": "2025",
"title": "未来技术太湖奖学金“创业实践一等奖”",
"hidden": false,
"awarder": "西安交通大学",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"description": ""
},
{
"id": "80648972-0619-48d9-82a3-7011f9e37a3c",
"date": "2023",
@@ -361,31 +369,31 @@
"label": "",
"inlineLink": false
},
"description": "<ul><li>面向微创外科手术记录“纯文字、难回溯、医生负荷重”的痛点,研发基于术中视频和医学知识库的图文病历自动生成系统</li><li>融合微调 LLM、多模态视觉语言模型、RAG 检索增强生成与 Mamba 智能体架构,构建“端-边-云”协同体系</li><li>单份图文报告生成时间缩短至约 <strong>30 秒</strong>,关键步骤识别精度超过 <strong>90%</strong>;累计生成 <strong>3000 余份</strong>图文手术报告</li><li>形成授权发明专利与软著,入选陕西首批“数据要素×”典型案例,获新华社、中国教育报等媒体报道</li></ul>"
"description": "<ul><li>面向微创外科手术记录“纯文字、难回溯、医生负荷重”的痛点,研发基于术中视频和医学知识库的图文病历自动生成系统</li><li>融合微调 LLM、多模态视觉语言模型、RAG 检索增强生成与 Mamba 智能体架构,构建“端-边-云”协同体系</li><li>单份图文报告生成时间缩短至约 <strong>30 秒</strong>,关键步骤识别精度超过 <strong>90%</strong>;累计生成 <strong>3000 余份</strong>图文手术报告</li><li>形成授权发明专利与软著,入选陕西首批“数据要素×”典型案例,获新华社、中国教育报等媒体报道</li></ul>"
},
{
"id": "17beb594-5513-4788-9db8-77cde270a476",
"name": "多设备兼容的术中影像记录分析系统",
"hidden": false,
"period": "2025",
"period": "2025 - 至今",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"description": "<ul><li>入选陕西首批 30 个“数据要素×”典型案例医疗健康方向</li><li>面向微创外科精准度与安全性提升,支持多设备兼容、术中影像记录、智能分析与质控</li></ul>"
"description": "<ul><li>入选陕西首批 30 个“数据要素×”典型案例医疗健康方向</li><li>面向微创外科精准度与安全性提升,支持多设备兼容、术中影像记录、智能分析与质控</li></ul>"
},
{
"id": "64fe9ea6-7efc-47f8-bed0-2fa883211ef4",
"name": "微创化手术智能导航平台建设",
"hidden": false,
"period": "2024 - 2026",
"period": "2024 - 至今",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"description": "<ul><li>西安交通大学第一附属医院医智慧研究院横向项目,工科负责人之一</li><li>围绕 CT、超声、腔镜图像开展分割识别、手术去雾、低剂量 CT 重建、多模态配准和临床报告生成探索</li><li>支撑中国国际大学生创新大赛、“华为杯”中国研究生人工智能创新大赛、“数据要素×”等项目</li></ul>"
"description": "<ul><li>西安交通大学第一附属医院医智慧研究院横向项目,工科负责人之一</li><li>围绕 CT、超声、腔镜图像开展分割识别、手术去雾、低剂量 CT 重建、多模态配准和临床报告生成探索</li><li>支撑中国国际大学生创新大赛、“华为杯”中国研究生人工智能创新大赛、“数据要素×”等项目</li></ul>"
},
{
"id": "d6691fc3-c992-482d-8592-3904b9bb1af1",
@@ -397,7 +405,7 @@
"label": "",
"inlineLink": false
},
"description": "<ul><li>西安交通大学基本科研业务费基础研究专项“自由探索学生类项目”,主持</li><li>探索多模态磁导航、器官模型配准和术中导航融合方法,支撑相关授权发明专利</li></ul>"
"description": "<ul><li>西安交通大学基本科研业务费基础研究专项“自由探索学生类项目”,主持,已结题</li><li>探索多模态磁导航、器官模型配准和术中导航融合方法,支撑相关授权发明专利</li></ul>"
}
],
"title": "核心项目",
@@ -420,7 +428,7 @@
"inlineLink": false
},
"location": "西安",
"description": "<ul><li>研究方向医工交叉、AI 腔镜外科导航、术中影像记录分析、多模态大模型与图文报告生成</li><li>现任医工学博士党支部宣传委员、未来技术学院 B2275 班学习委员</li></ul>"
"description": "<ul><li><u><strong>课题组:</strong>西安交通大学外科梦工场吕毅教授课题组;<strong>导师:</strong>吴荣谦教授</u></li><li>研究方向医工交叉、AI 腔镜外科导航、术中影像记录分析、多模态大模型与图文报告生成</li><li>现任医工学博士党支部宣传委员、未来技术学院 B2275 班学习委员</li></ul>"
},
{
"id": "17863b1a-cfbc-4990-b870-ff3485048b40",
@@ -428,7 +436,7 @@
"grade": "",
"degree": "本科",
"hidden": false,
"period": "2018.08 - 2022.07",
"period": "2018.09 - 2022.07",
"school": "西安交通大学人工智能学院",
"website": {
"url": "",
@@ -436,7 +444,7 @@
"inlineLink": false
},
"location": "西安",
"description": "<p>人工智能背景,支撑后续医学影像算法、临床大模型和智能外科工程研发</p>"
"description": "<p>人工智能背景,支撑后续医学影像算法、临床大模型和智能外科工程研发</p>"
},
{
"id": "125d97f1-ba3f-4331-9998-005d534c5aeb",
@@ -465,7 +473,7 @@
"id": "469cc8d5-b041-4f02-9ce5-83961cc00727",
"icon": "stethoscope",
"name": "医工交叉",
"hidden": false,
"hidden": true,
"keywords": [
"AI + 临床",
"智能外科"
@@ -476,7 +484,7 @@
"id": "43ab8051-a0e5-422e-b2b1-90c6a30311da",
"icon": "rocket",
"name": "成果转化",
"hidden": false,
"hidden": true,
"keywords": [
"创业资助",
"专利转化",
@@ -488,7 +496,7 @@
"id": "4721af36-ae2a-47f4-9852-4d71e7f5e44a",
"icon": "database",
"name": "数据要素",
"hidden": false,
"hidden": true,
"keywords": [
"术中影像",
"质控",
@@ -498,7 +506,7 @@
}
],
"title": "关键词",
"hidden": false,
"hidden": true,
"columns": 1
},
"languages": {
@@ -520,54 +528,54 @@
{
"id": "50c41f06-d048-48d6-a5b0-c67521104562",
"hidden": false,
"period": "2022 - 至今",
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "西安交通大学",
"description": "<p>宣传委员</p>",
"location": "",
"description": "<p>2022 - 至今<br/>宣传委员</p>",
"organization": "未来技术学院医工学博士党支部"
},
{
"id": "bbc0e308-86db-4894-a136-24b80555d25c",
"hidden": false,
"period": "2024",
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "西安交通大学",
"description": "<p>学习部负责人;第十五次研究生代表大会代表</p>",
"location": "",
"description": "<p>2024<br/>学习部负责人;第十五次研究生代表大会代表</p>",
"organization": "未来技术学院学生会 / 研究生会"
},
{
"id": "72a41ed7-2ce6-4c36-a524-aac20b6a7bef",
"hidden": false,
"period": "2026",
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "西安交通大学",
"description": "<p>第一次党员代表大会代表</p>",
"location": "",
"description": "<p>2026<br/>第一次党员代表大会代表</p>",
"organization": "未来技术学院党员代表大会"
},
{
"id": "0e5bd75f-e635-44c7-8379-43fdc2dc45b8",
"hidden": false,
"period": "2022 - 至今",
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "西安交通大学",
"description": "<p>学习委员</p>",
"organization": "未来技术学院 B2275 班"
"location": "",
"description": "<p>2022 - 至今<br/>学习委员</p>",
"organization": "未来技术学院 B2275 班班委"
}
],
"title": "校内任职",
@@ -589,7 +597,7 @@
},
"location": "",
"position": "主要工科参与人",
"description": "<p>基于多模态磁导航技术的困难气道插管系统的研究49 万元在研</p>"
"description": "<p>基于多模态磁导航技术的困难气道插管系统的研究49 万元在研</p>"
},
{
"id": "0b95f627-8395-4242-9f60-8fa0f1bb491c",
@@ -603,8 +611,8 @@
"inlineLink": true
},
"location": "",
"position": "在研",
"description": "<p>微创化手术智能导航平台建设</p>"
"position": "主要工科参与人(第三序位)",
"description": "<p>微创化手术智能导航平台建设70万元在研</p>"
},
{
"id": "47a5d869-535f-42e2-8ac7-bd927853b973",
@@ -619,7 +627,7 @@
},
"location": "",
"position": "主持",
"description": "<p>磁定位辅助多模态融合微创手术组织自动配准系统3 万元,在研。</p>"
"description": "<p>磁定位辅助多模态融合微创手术组织自动配准系统3 万元;结题</p>"
},
{
"id": "affc3464-2cad-4ea4-8399-162bd2c2e2eb",
@@ -633,8 +641,8 @@
"inlineLink": false
},
"location": "",
"position": "结题",
"description": "<p>微创手术导航训练虚拟平台建设研究</p>"
"position": "主要工科参与人(第三序位)",
"description": "<p>微创手术导航训练虚拟平台建设研究20万元结题</p>"
},
{
"id": "eb837f99-e47f-4ec9-9c8b-0fb879302889",
@@ -648,8 +656,8 @@
"inlineLink": false
},
"location": "",
"position": "参与第二位,结题",
"description": "<p>微创化手术智能导航平台建设3 万元</p>"
"position": "主要工科参与人(第三序位)",
"description": "<p>微创化手术智能导航平台建设3 万元;结题</p>"
}
],
"title": "科研与获批项目",
@@ -675,7 +683,7 @@
"inlineLink": false
},
"publisher": "Frontiers in Oncology",
"description": "<p>共同第一作者</p>"
"description": "<p>共同第一作者</p>"
},
{
"id": "857e2a52-7818-404c-8350-2df1aed6cc4b",
@@ -688,7 +696,7 @@
"inlineLink": false
},
"publisher": "中华肝脏外科手术学电子杂志",
"description": "<p>共同一作方向,腔镜术中图像处理</p>"
"description": "<p>共同一作作者,腔镜术中图像处理</p>"
},
{
"id": "97e962cf-49ca-434c-92c2-b73e8fc64147",
@@ -701,7 +709,7 @@
"inlineLink": false
},
"publisher": "中华肝脏外科手术学电子杂志",
"description": "<p>共同一作方向,腔镜肝脏外科与扩展现实综述</p>"
"description": "<p>共同一作作者,腔镜肝脏外科与扩展现实综述</p>"
}
],
"title": "论文与会议",
@@ -715,7 +723,7 @@
"date": "ZL 2022 1 1431692.6",
"title": "一种抗干扰单面导通微针电极及制备方法",
"hidden": false,
"issuer": "发明专利",
"issuer": "第一发明人 授权发明专利",
"website": {
"url": "",
"label": "",
@@ -728,7 +736,7 @@
"date": "ZL 2022 1 0478532.0",
"title": "一种具有绝缘薄膜的微针阵列电极及其制备方法",
"hidden": false,
"issuer": "发明专利",
"issuer": "第二发明人 授权发明专利",
"website": {
"url": "",
"label": "",
@@ -741,7 +749,7 @@
"date": "ZL 2024 1 1527344.8",
"title": "一种微创腔镜图文报告生成系统",
"hidden": false,
"issuer": "发明专利",
"issuer": "第二发明人 授权发明专利",
"website": {
"url": "",
"label": "",
@@ -754,7 +762,7 @@
"date": "ZL 2025 1 0096041.3",
"title": "一种实时术中腔镜影像分析装置及方法",
"hidden": false,
"issuer": "发明专利",
"issuer": "第二发明人 授权发明专利",
"website": {
"url": "",
"label": "",
@@ -767,7 +775,7 @@
"date": "ZL 2025 1 0896530.7",
"title": "模型与器官配准以辅助导航的方法及系统",
"hidden": false,
"issuer": "发明专利",
"issuer": "第二发明人 授权发明专利",
"website": {
"url": "",
"label": "",
@@ -780,7 +788,7 @@
"date": "ZL 2025 1 0933524.4",
"title": "一种喉部的体外磁靶标定位导航方法及系统",
"hidden": false,
"issuer": "发明专利",
"issuer": "第二发明人 授权发明专利",
"website": {
"url": "",
"label": "",
@@ -793,7 +801,7 @@
"date": "ZL 2025 1 0766744.2",
"title": "一种带有旋转永磁定位和选择性脑降温功能的气管导管装置及其温度控制方法",
"hidden": false,
"issuer": "发明专利",
"issuer": "第二发明人 授权发明专利",
"website": {
"url": "",
"label": "",
@@ -806,7 +814,7 @@
"date": "ZL 2023 1 0062566.6",
"title": "一种多模态脑信号的意识状态分类系统及方法",
"hidden": false,
"issuer": "发明专利",
"issuer": "授权发明专利",
"website": {
"url": "",
"label": "",
@@ -819,7 +827,7 @@
"date": "ZL 2022 1 1379675.2",
"title": "一种用于临床意识检测的多模态信号采集眼罩",
"hidden": false,
"issuer": "发明专利",
"issuer": "授权发明专利",
"website": {
"url": "",
"label": "",
@@ -867,9 +875,9 @@
{
"id": "c16d28b2-3a20-4c5e-b1ef-419139406aad",
"date": "2025",
"title": "全国大学生电子商务“创新、创意及创业”挑战赛(三创赛)国赛一等奖",
"title": "国家级一等奖|数智生命",
"hidden": false,
"awarder": "三创赛组委会",
"awarder": "全国大学生电子商务“创新、创意及创业”挑战赛(三创赛) / 三创赛竞赛组织委员会",
"website": {
"url": "",
"label": "",
@@ -880,35 +888,9 @@
{
"id": "5c350966-0a9f-4c51-8bcc-77c16af29a32",
"date": "2025",
"title": "第七届智慧医疗创新大赛国赛三等奖",
"title": "国家级三等奖|智康智影——智能外科向临床盲点进军",
"hidden": false,
"awarder": "智慧医疗创新大赛",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"description": ""
},
{
"id": "f9966d23-051a-4b4b-91b5-37238f32194d",
"date": "2024",
"title": "中国国际大学生创新大赛陕西赛区一等奖/金奖",
"hidden": false,
"awarder": "中国国际大学生创新大赛",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"description": ""
},
{
"id": "bef745cf-22a4-42b2-9ab7-677325da0b99",
"date": "2024",
"title": "“数据要素×”大赛陕西分赛医疗健康赛道一等奖",
"hidden": false,
"awarder": "陕西分赛组委会",
"awarder": "第七届智慧医疗创新大赛 / 智慧医疗创新大赛组委会",
"website": {
"url": "",
"label": "",
@@ -919,9 +901,9 @@
{
"id": "de9d4e86-e8b1-4330-ba88-c3dc399a8f50",
"date": "2024",
"title": "“挑战杯”大学生创业计划竞赛国赛三等奖",
"title": "国家级三等奖|杏林创涂——新一代纳米抗菌涂层领跑者",
"hidden": false,
"awarder": "挑战杯",
"awarder": "挑战杯”中国大学生创业计划竞赛 / 共青团中央、教育部等",
"website": {
"url": "",
"label": "",
@@ -932,22 +914,9 @@
{
"id": "f974f854-cf64-4559-8adb-7da582dd8848",
"date": "2024",
"title": "中国医疗器械创新创业大赛国赛二等奖",
"title": "国家级二等奖|新一代纳米抗菌涂层领跑者",
"hidden": false,
"awarder": "中国医疗器械创新创业大赛",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"description": ""
},
{
"id": "36b7e7bc-1e0f-439e-8f3a-f72f80c7a3a4",
"date": "2024",
"title": "中美青年创客大赛省赛一等奖",
"hidden": false,
"awarder": "中美青年创客大赛",
"awarder": "第七届中国医疗器械创新创业大赛 / 中国医疗器械创新创业大赛组委会",
"website": {
"url": "",
"label": "",
@@ -958,9 +927,9 @@
{
"id": "066baf54-be1a-4c56-8cb5-d02d33f62a2d",
"date": "2024",
"title": "第六届智慧医疗创新大赛国赛二等奖",
"title": "国家级二等奖|云链智康——新一代智能外科机要助理",
"hidden": false,
"awarder": "智慧医疗创新大赛",
"awarder": "第六届智慧医疗创新大赛 / 智慧医疗创新大赛组委会",
"website": {
"url": "",
"label": "",
@@ -971,9 +940,9 @@
{
"id": "4b5cc7f2-f4af-484a-93ee-cc588cde720a",
"date": "2023",
"title": "产业融合发展——新工科创新大赛国赛一等奖",
"title": "国家级一等奖云链智康——AI 辅助智能外科助手",
"hidden": false,
"awarder": "新工科创新大赛",
"awarder": "产业融合发展——新工科创新大赛 / 工业和信息化部工业文化发展中心",
"website": {
"url": "",
"label": "",
@@ -984,9 +953,9 @@
{
"id": "6a635d4a-ac93-4093-8574-6deba541d5c7",
"date": "2023",
"title": "“华为杯”中国研究生人工智能创新大赛国赛二等奖",
"title": "国家级二等奖|云链智康:数据驱动下的临床微创手术新模式",
"hidden": false,
"awarder": "中国研究生人工智能创新大赛",
"awarder": "“华为杯”中国研究生人工智能创新大赛 / 中国学位与研究生教育学会、中国科协青少年科技中心",
"website": {
"url": "",
"label": "",
@@ -997,9 +966,9 @@
{
"id": "1171ae8b-e753-48f2-9866-c0cc50dbdbe2",
"date": "2023",
"title": "第二届中国研究生“双碳”创新与创意大赛国赛二等奖",
"title": "国家级二等奖",
"hidden": false,
"awarder": "中国研究生“双碳”创新与创意大赛",
"awarder": "第二届中国研究生“双碳”创新与创意大赛 / 中国学位与研究生教育学会、中国科协青少年科技中心",
"website": {
"url": "",
"label": "",
@@ -1010,9 +979,48 @@
{
"id": "2ee9d130-c5e6-4ba3-a1b7-6864726d4cee",
"date": "2023",
"title": "三创赛国赛二等奖",
"title": "国家级二等奖|术中宝——新一代术中医疗影像分析辅助装置",
"hidden": false,
"awarder": "三创赛组委会",
"awarder": "全国大学生电子商务“创新、创意及创业”挑战赛(三创赛) / 三创赛竞赛组织委员会",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"description": ""
},
{
"id": "f9966d23-051a-4b4b-91b5-37238f32194d",
"date": "2024",
"title": "省部级一等奖|智康智影——数字化赋能外科全链条诊疗体系",
"hidden": false,
"awarder": "中国国际大学生创新大赛陕西赛区 / 陕西省教育厅",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"description": ""
},
{
"id": "bef745cf-22a4-42b2-9ab7-677325da0b99",
"date": "2024",
"title": "省部级一等奖|智康智影——数字化赋能新型临床体系构建",
"hidden": false,
"awarder": "“数据要素×”大赛陕西分赛医疗健康赛道 / 陕西省数据和政务服务局",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"description": ""
},
{
"id": "36b7e7bc-1e0f-439e-8f3a-f72f80c7a3a4",
"date": "2024",
"title": "省部级一等奖|适用于肝胆外科手术的术中智能导航平台",
"hidden": false,
"awarder": "中美青年创客大赛陕西赛区 / 教育部",
"website": {
"url": "",
"label": "",
@@ -1023,9 +1031,9 @@
{
"id": "f9b63a59-7e30-4df5-984b-d9288288f1e2",
"date": "2023",
"title": "中国国际“互联网+”大学生创新创业大赛省赛一等奖/金奖",
"title": "省部级一等奖|云链智康——新一代智能外科机要助理",
"hidden": false,
"awarder": "互联网+ 大赛",
"awarder": "中国国际“互联网+”大学生创新创业大赛陕西赛区 / 陕西省教育厅",
"website": {
"url": "",
"label": "",
@@ -1041,119 +1049,202 @@
{
"id": "802bfd76-af47-4c5d-8b04-f89a38932dcd",
"type": "volunteer",
"title": "学术组织任职",
"hidden": false,
"columns": 1,
"items": [
{
"id": "76390bf9-49d5-4fbc-98ab-6691477e982d",
"hidden": false,
"period": "2024 - 至今",
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "",
"organization": "中医药信息学会中西医外科智能诊疗分会",
"description": "<p>理事。</p>"
"description": "<p>2024 - 至今<br/>理事</p>",
"organization": "中医药信息学会中西医外科智能诊疗分会"
},
{
"id": "c2291187-b7c3-48a2-820a-40e06836072b",
"hidden": false,
"period": "2024 - 至今",
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "",
"organization": "中国抗癌协会",
"description": "<p>青年理事。</p>"
"description": "<p>2024 - 至今<br/>青年理事</p>",
"organization": "中国抗癌协会"
},
{
"id": "1f873e55-fde9-4b85-ab6f-d151d5b15d53",
"hidden": false,
"period": "2024 - 至今",
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "",
"organization": "中国医药教育协会数字医疗专业委员会",
"description": "<p>委员。</p>"
"description": "<p>2024 - 至今<br/>数字医疗专业委员会委员</p>",
"organization": "中国医药教育协会"
},
{
"id": "e1bf3d6a-6018-4c3e-9330-3e7c3a2d89a1",
"hidden": false,
"period": "2024 - 至今",
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "",
"organization": "中国生物医学工程学会",
"description": "<p>学生会员。</p>"
"description": "<p>2024 - 至今<br/>学生会员</p>",
"organization": "中国生物医学工程学会"
}
]
],
"title": "学术组织任职",
"hidden": false,
"columns": 1
},
{
"id": "1014b66d-3de1-4bf4-903c-93e7f07c8f81",
"type": "summary",
"title": "论文与会议",
"hidden": false,
"columns": 1,
"items": [
{
"id": "c16accb2-c907-44bf-aa0c-89e0085c6a50",
"hidden": false,
"content": "<p><strong>期刊论文</strong></p><ul><li><strong>Frontiers in Oncology, 2025</strong> — Development of an AI-driven digital assistance system for real-time safety evaluation and quality control in laparoscopic liver surgery共同第一作者</li><li><strong>中华肝脏外科手术学电子杂志, 2023/2024</strong> — 智能化辅助图像实时去雾技术在腹腔镜胆囊切除术中的应用研究(共同一作方向</li><li><strong>中华肝脏外科手术学电子杂志, 2023</strong> — 增强现实、虚拟现实与混合现实在腔镜肝脏外科中的应用进展(共同一作方向</li></ul><p><strong>ACS Clinical Congress 2025</strong></p><ul><li><strong>Oral</strong> — Biomarker Risk Scoring Algorithm And Preoperative Stratification In Patients With Lung Cancer</li><li><strong>Oral</strong> — Differences In Perioperative Complications Of Laparoscopic Cholecystectomy Under Voice Risk Alert System (V-RAS)-assisted Monitoring: Results From 48 Hospitals In Western China</li><li><strong>Oral</strong> — Intraoperative Smart Molecular Imaging And Recognition To Enhance Surgeons' Ability To Identify Lymph Node Metastasis In Thyroid Surgery</li><li><strong>Oral</strong> — Comparison Of Robot-assisted And Conventional Laparoscopic Hepatectomy For Perioperative Outcomes: A Clinical Randomized Controlled Trial (RCT)</li><li><strong>ePoster</strong> — Cost-effectiveness Of Endoscopic Magnetic Traction Technology Compared To Laparoscopic Heller Myotomy For The Treatment Of Achalasia</li></ul><p><strong>ACS / CMAIC / FIS 2023</strong></p><ul><li><strong>ePoster</strong> — Application Of Computer Intelligent Surgical Confidential Assistant In Laparoscopic Liver Resection</li><li><strong>ePoster</strong> — Intelligent Surgical Confidential Assistant Helps Precise Magnetic Assisted Vascular Anastomosis</li><li><strong>Poster</strong> — Intelligent Surgery Enters the Blind Spot of Lumpectomy Liver Resection</li><li><strong>Poster</strong> — Intelligent digital fogging technology shows great potential in laparoscopic hepatectomy surgery</li><li><strong>Poster</strong> — Prospects for intelligent surgical machine assistants in precision liver segment resection</li><li><strong>Poster</strong> — Application of Orthogonal Decomposition in Surgical Image Segmentation - for Unsupervised Adaptability in Intraoperative Surgical Image Recognition Navigation</li><li><strong>Poster</strong> — Intraoperative Image Detection and Clearing System Based on Generative Adversarial Network</li></ul>"
"content": "<p><strong>期刊论文</strong></p><ul><li><p><strong>Frontiers in Oncology, 2025</strong> — Development of an AI-driven digital assistance system for real-time safety evaluation and quality control in laparoscopic liver surgery共同第一作者</p></li><li><p><strong>中华肝脏外科手术学电子杂志, 2023</strong> — 智能化辅助图像实时去雾技术在腹腔镜胆囊切除术中的应用研究(共同一作作者</p></li><li><p><strong>中华肝脏外科手术学电子杂志, 2023</strong> — 增强现实、虚拟现实与混合现实在腔镜肝脏外科中的应用进展(共同一作作者</p></li></ul><p><strong>ACS Clinical Congress 2025</strong></p><ul><li><p><strong>Oral</strong> — Biomarker Risk Scoring Algorithm And Preoperative Stratification In Patients With Lung Cancer</p></li><li><p><strong>Oral</strong> — Differences In Perioperative Complications Of Laparoscopic Cholecystectomy Under Voice Risk Alert System (V-RAS)-assisted Monitoring: Results From 48 Hospitals In Western China</p></li><li><p><strong>Oral</strong> — Intraoperative Smart Molecular Imaging And Recognition To Enhance Surgeons Ability To Identify Lymph Node Metastasis In Thyroid Surgery</p></li><li><p><strong>Oral</strong> — Comparison Of Robot-assisted And Conventional Laparoscopic Hepatectomy For Perioperative Outcomes: A Clinical Randomized Controlled Trial (RCT)</p></li><li><p><strong>ePoster</strong> — Cost-effectiveness Of Endoscopic Magnetic Traction Technology Compared To Laparoscopic Heller Myotomy For The Treatment Of Achalasia</p></li></ul><p></p><p><strong>ACS / CMAIC / FIS 2023</strong></p><ul><li><strong>ePoster</strong> — Application Of Computer Intelligent Surgical Confidential Assistant In Laparoscopic Liver Resection</li><li><strong>ePoster</strong> — Intelligent Surgical Confidential Assistant Helps Precise Magnetic Assisted Vascular Anastomosis</li><li><strong>Poster</strong> — Intelligent Surgery Enters the Blind Spot of Lumpectomy Liver Resection</li><li><strong>Poster</strong> — Intelligent digital fogging technology shows great potential in laparoscopic hepatectomy surgery</li><li><strong>Poster</strong> — Prospects for intelligent surgical machine assistants in precision liver segment resection</li><li><strong>Poster</strong> — Application of Orthogonal Decomposition in Surgical Image Segmentation - for Unsupervised Adaptability in Intraoperative Surgical Image Recognition Navigation</li><li><strong>Poster</strong> — Intraoperative Image Detection and Clearing System Based on Generative Adversarial Network</li></ul>"
}
]
],
"title": "论文与会议",
"hidden": false,
"columns": 1
},
{
"id": "54972f49-3c83-4912-b429-dc659a02eda9",
"type": "summary",
"title": "创新创业与成果转化",
"hidden": false,
"columns": 1,
"items": [
{
"id": "8ed9e356-fdbf-4c6b-bf15-4033f40b85f5",
"hidden": false,
"content": "<ul><li><strong>专利转化</strong>2026 年相关专利完成 <strong>50 万元</strong>技术许可 / 转化公示</li><li><strong>创业实践</strong>2023 年依托大学生创新创业实践创立公司,获 <strong>30 万元</strong>创业资助</li><li><strong>典型案例</strong>2025 年“多设备兼容的术中影像记录分析系统”入选陕西首批 <strong>30 个</strong>“数据要素×”典型案例。</li></ul>"
"content": "<ul><li><p><strong>专利转化</strong>2026 年相关专利完成 <strong>50 万元</strong>技术许可 / 转化公示</p></li><li><p><strong>创业实践</strong>2023 年依托大学生创新创业实践创立公司,获 <strong>30 万元</strong>创业资助</p></li><li><p><strong>典型案例</strong>2025 年“多设备兼容的术中影像记录分析系统”入选陕西<strong>首批“数据要素×”典型案例</strong></p></li></ul><p></p>"
}
]
],
"title": "创新创业与成果转化",
"hidden": false,
"columns": 1
},
{
"id": "559335bd-99c4-44c2-97b6-420b011415f7",
"type": "summary",
"title": "媒体报道",
"hidden": false,
"columns": 1,
"items": [
{
"id": "83f3ac59-c07d-4270-b786-9234eaf1da92",
"hidden": false,
"content": "<ul><li><strong>新华社</strong>《西安交通大学:扎根西部传薪火 服务国家育英才》2026报道人与项目实践</li><li><strong>中国教育报</strong>《产学研“抱团”闯出创新路》2025关注创新港产学研协同实践</li><li><strong>陕西省科学技术厅</strong>转载陕西网《后端深度融合科学研究突破“围墙之困”》2025</li></ul>"
"content": "<ul><li><p><strong>新华社</strong>《西安交通大学:扎根西部传薪火 服务国家育英才》2026130周年校庆专栏其中报道人与项目实践</p></li><li><p><strong>中国教育报</strong>《产学研“抱团”闯出创新路》2025关注创新港产学研协同实践,其中报道本人与项目实践</p></li><li><p><strong>陕西省科学技术厅</strong>转载《后端深度融合科学研究突破“围墙之困”》2025关注复合型创新创业人才培养</p></li></ul><p></p>"
}
]
],
"title": "社会影响",
"hidden": false,
"columns": 1
},
{
"id": "752ddba0-3400-4e33-8f54-49fc3a4b57b9",
"type": "summary",
"title": "编写著作",
"hidden": false,
"columns": 1,
"type": "certifications",
"items": [
{
"id": "b71f628c-04ea-4d2b-9452-35f4dc0e0054",
"date": "2024",
"title": "参编《2024中国数字医疗创新发展蓝皮书》",
"hidden": false,
"content": "<ul><li>编委参编《2024中国数字医疗创新发展蓝皮书》。</li></ul>"
"issuer": "编委",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"description": ""
}
]
],
"title": "编写著作",
"hidden": false,
"columns": 1
},
{
"id": "f4fa59b9-34cf-41b0-b324-f07f07934fc2",
"type": "summary",
"items": [
{
"id": "dff431c1-739e-48f9-a835-6c8b1965c5a3",
"hidden": false,
"content": "<p><strong>西安交通大学外科梦工场</strong><br/>吕毅教授课题组<br/><strong>导师:</strong>吴荣谦教授</p>"
}
],
"title": "所在课题组",
"hidden": false,
"columns": 1
},
{
"id": "4a9c2385-2f79-4d50-99cf-6d2aaef0b6c6",
"type": "volunteer",
"items": [
{
"id": "fc2f36e5-6737-4c77-a6e7-9d9a4f9f7250",
"hidden": false,
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "",
"description": "<p>2024<br/>科技副校长聘任 / 中学宣讲与科普服务</p>",
"organization": "西安市高陵区第四中学教育集团"
},
{
"id": "ce1f0d77-245c-4145-9b30-0ff274b31834",
"hidden": false,
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "",
"description": "<p>2022.12.30<br/>全血 400ml</p>",
"organization": "无偿献血"
},
{
"id": "a8b80562-00fc-42bb-ad5a-d8a0cd10bbbf",
"hidden": false,
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "",
"description": "<p>2023.06.03<br/>成分血 2 治疗量</p>",
"organization": "无偿献血"
},
{
"id": "215ce4ab-7703-4d6e-9396-513c64877ead",
"hidden": false,
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "",
"description": "<p>2024.10.12<br/>全血 200ml</p>",
"organization": "无偿献血"
}
],
"title": "志愿及社会活动",
"hidden": false,
"columns": 1
}
]
}