Files
app_03_pregnancy/docs/development-standards-v2.md

792 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# H5 小程序嵌入式应用开发模板 v2
> 适用于嵌入微信小程序 web-view 的 H5 应用
> 技术栈:FastAPI + MongoDB (Beanie) + React 18 + React Router 7 + Vite 6
> 认证:小程序传 openid → 后端调 CRMEB 接口获取用户信息 → 自动补齐昵称/头像
>
> **v2 变更要点(实战沉淀)**
> 1. 前端资源改**相对路径**(Vite `base: './'`),适配二级目录与 web-view,避免白屏。
> 2. Nginx 无斜杠跳转**保留 query**,避免 `?openid` 丢失导致登录失败。
> 3. 二级目录三处一致规则细化(base / basename / API 前缀)。
> 4. 新增:小程序 web-view 接入要点、MongoDB 部署注意、时区统一、后台管理 + 访问埋点。
---
## 一、项目架构
### 1.1 技术栈
| 层 | 技术 | 备注 |
|---|------|------|
| 前端 | React 18 + React Router 7 + Vite 6 | SPA,二级目录部署,资源相对路径 |
| UI | TailwindCSS + 自研 SVG 图标(按需 MUI/shadcn)| 母婴等品牌建议自研图标,避免堆依赖 |
| 状态 | React Context + localStorage 持久化 | 无 Redux/Zustand |
| 后端 | FastAPI + Uvicorn(PM2 守护,`--interpreter none`)| |
| 数据库 | MongoDB(Motor + Beanie ODM)| localhost 绑定 + 鉴权 |
| 认证 | openid → 后端调 CRMEB 内部接口 | 无 JWT 签名验证 |
### 1.2 目录结构
```
project/
├── frontend/
│ ├── src/app/
│ │ ├── pages/ # 页面组件
│ │ ├── components/ # 通用组件(含自研 icons.tsx)
│ │ ├── context/ # React Context
│ │ ├── hooks/ # 自定义 hook(可独立测试)
│ │ ├── services/ # api / debug / analytics
│ │ ├── data/ # 静态数据表
│ │ ├── routes.tsx # 路由 + 埋点 RootTracker
│ │ └── App.tsx
│ ├── .env / .env.production # 环境变量(生产关闭 debug)
│ └── dist/ # 构建产物
├── backend/
│ ├── app/
│ │ ├── routers/ # auth/user/sessions/events/admin...
│ │ ├── models/ # Beanie 数据模型
│ │ ├── services/ # 业务逻辑 + jwt_util
│ │ ├── middleware/ # 认证(get_current_user / get_admin)
│ │ └── config.py
│ ├── tests/ # pytest(建议测试占比高)
│ ├── serv.sh / backup.sh
│ └── .env
├── deploy/
│ ├── nginx-<app>.conf
│ └── admin/index.html # 独立后台(单文件,无需构建)
└── docs/
```
### 1.3 多应用同域名 / 二级目录部署
```
同一 Nginx 下多个 H5 应用,各自独立后端进程;前端按二级目录部署:
https://mp.example.com/
├── /app_03/ → 前端 SPA(部署在二级目录 /app_03/)
│ └── /app_03/api/ → proxy_pass 127.0.0.1:8030/api/
├── /baby_food/ → /baby_food/api/ → 8031
└── /admin/(可选) → 独立后台静态页
```
---
## 二、前端路径规范(v2 核心,务必遵守)
H5 会被微信小程序 web-view 以二级目录地址打开(如 `…/app_03/?openid=xxx`)。
路径处理不当是 web-view **白屏**最常见原因。规则如下:
### 2.1 资源用相对路径(关键)
```ts
// vite.config.ts
export default defineConfig({
base: "./", // ✅ 相对路径:资源按文档所在二级目录解析
// ❌ 不要用绝对 base: "/app_03/"(小程序/代理环境下易解析错导致白屏)
});
```
- `base: './'` 后,构建出的 `index.html` 引用为 `./assets/xxx.js`,无论挂在哪个二级目录都能正确加载。
- 单层路由(`/home`、`/count`)下,`./assets/` 从 `/app_03/home` 解析回 `/app_03/assets/`,正常。
- 如有**多层路由**,需改用 HashRouter 或加 `<base href>`,否则深层路由刷新会算错相对基准。
### 2.2 二级目录三处一致
| 配置项 | 位置 | 值(以 app_03 为例)| 说明 |
|--------|------|------|------|
| Vite `base` | `vite.config.ts` | `'./'` | **相对**,不写死二级目录 |
| Router `basename` | `routes.tsx` | `'/app_03'` | 路由仍按二级目录(路径里编码二级目录)|
| `VITE_API_PREFIX` | `.env` | `'/app_03/api'` | 同源绝对路径,web-view 同源可用 |
> 资源相对、路由二级目录、API 同源绝对——三者配合:资源可移植,路由/接口仍锚定二级目录。
### 2.3 生产环境关闭调试
```ini
# .env.production
VITE_API_PREFIX=/app_03/api
VITE_DEBUG=false # 生产关闭:避免无 openid 时自动 dev-login
```
`npm run build` 默认加载 `.env.production`。
### 2.4 Vite dev proxy
```ts
server: {
proxy: {
[apiPrefix]: { // "/app_03/api"
target: "http://localhost:8030",
changeOrigin: true,
rewrite: (p) => p.replace(/^\/app_03\/api/, "/api"),
},
},
}
```
---
## 三、小程序 web-view 接入要点(v2 新增)
1. **业务域名白名单**:小程序后台「业务域名」必须加入本 H5 域名(需 HTTPS、需下载校验文件放服务器)。否则 web-view 直接打不开。
2. **打开地址**:`https://mp.example.com/app_03/?openid=<openid>`,建议带尾斜杠。
3. **跳转保留参数**:无斜杠地址 `…/app_03?openid=xxx` 经 nginx 301 时**必须保留 query**,否则 openid 丢失、登录失败:
```nginx
location = /app_03 { return 301 /app_03/$is_args$args; } # ✅ 保留 ?openid
# ❌ return 301 /app_03/; 会丢掉 ?openid
```
4. **openid 读取**:用 BrowserRouter 时 `useSearchParams()` 能读到 `?openid`。若改 HashRouter,`?openid` 在 `#` 之前不会被 router 读到,需 `new URLSearchParams(window.location.search)` 直接取。
5. **真机调试**:白屏先用 vConsole / 真机调试看是不是资源 404 或域名未白名单。
---
## 四、认证规范
### 4.1 流程
```
小程序 web-view → /app_03/?openid=xxx
→ LoginPage 读取 openid
→ POST /app_03/api/auth/openid-login { openid }
→ 后端 GET CRMEB /api/internal/user-info?openid=xxx (X-Internal-Key)
→ { nickname, phone, avatar } → 建/更新 User
→ 返回 token → setTokenAndUser → fetchProfile → 跳首页/引导页
```
### 4.2 关键约定
- 中间件 `get_current_user`:解 token(`verify_signature=False`),手动验 `exp`,按 openid 查/建用户。
- CRMEB 客户端**任何异常都优雅降级**(返回空昵称/头像,不阻断登录),并 `logger.warning` 记录,便于排查。
- `DEV_MODE=1` 才启用 `POST /api/auth/dev-login`,生产必须为 `0`。
- **CRMEB 匹配口径**:openid 按 appid 隔离。若 CRMEB 与小程序非同一 appid,应改用 **unionid** 匹配(小程序传 unionid,CRMEB 内部接口按 unionid 查;openid 仍作本系统主键)。
- **H5 端 openid 一致性检查**(分享场景必做):当 H5 被其他用户分享的链接打开时,web-view 的 `localStorage` 可能残留原用户的 token。H5 的路由守卫(如 `ProtectedRoute`)必须比对**当前 URL 中的 openid** 与 **本地缓存 token 中的 openid**,不一致时清除缓存重新走登录流程:
```tsx
const urlOpenid = new URLSearchParams(window.location.search).get('openid');
if (urlOpenid && token) {
const payload = decodeToken(token);
if (payload.openid !== urlOpenid) {
clearToken(); // 清除原用户缓存
return <Navigate to="/" replace />; // 重新登录
}
}
```
> 否则会出现:B 打开 A 分享的链接,URL 中已是 B 的 openid,但 H5 读取到 A 的缓存 token,仍然展示 A 的数据。
---
## 五、后端规范
### 5.1 路由注册
```python
# routers/__init__.py
router = APIRouter(prefix="/api")
router.include_router(auth.router) # /api/auth/*
router.include_router(user.router) # /api/user/*
router.include_router(sessions.router) # /api/sessions/*
router.include_router(events.router) # /api/events (埋点上报)
router.include_router(admin.router) # /api/admin/* (后台)
```
> 注意路由顺序:`/sessions/stats` 必须声明在 `/sessions/{id}` 之前,否则 `stats` 被当作 id。
### 5.2 配置(pydantic-settings)
```ini
# .env
MONGODB_URL=mongodb://user:pwd@127.0.0.1:27017/app_03?authSource=admin
MONGODB_DB_NAME=app_03
JWT_SECRET_KEY=...
JWT_EXPIRE_DAYS=30
CRMEB_BASE_URL=https://shop.example.com
CRMEB_API_KEY=sk_...
HOST=0.0.0.0
PORT=8030
DEV_MODE=0
ADMIN_USER=admin
ADMIN_PASS=<强密码> # 后台登录,生产务必改默认
CORS_ORIGINS=https://mp.example.com # 生产收紧,勿用 *
```
### 5.3 时区统一(v2 新增,重要)
- 数据库统一存 **UTC**(`datetime.now(timezone.utc)`)。
- 一切「按天分组 / 今天 / 日期反推」统一按**目标时区**(国内=北京 UTC+8)换算,前后端一致:
- 后端聚合按天:把 `created_at` `astimezone(UTC+8)` 再取 `.date()`。
- 前端分组:`new Date(iso).toLocaleDateString('zh-CN')`(设备本地=北京)。
- 由 UTC 存储反推业务日期时,先转北京再比较,避免凌晨跨日差一天。
### 5.4 数据模型
```python
class User(Document):
openid: str = Field(..., unique=True)
name: str = ""
phone: Optional[str] = None
avatar_url: Optional[str] = None
is_onboarded: bool = False
created_at: datetime
class Settings:
name = "users"
indexes = [IndexModel("openid", unique=True)]
```
> 业务集合自定义;为常用查询建复合索引(如 `[(openid,1),(created_at,-1)]`)。
---
## 六、后台管理 + 访问埋点(v2 新增)
### 6.1 管理员鉴权
```python
def build_admin_token(): # jwt {"admin": True, exp}
def get_admin(credentials): # 校验 payload["admin"],否则 401/403
```
- `POST /api/admin/login {username,password}` 校验 `.env` 的 ADMIN_USER/PASS → 发 admin token。
- 后台接口统一 `Depends(get_admin)`。
### 6.2 独立后台页
- **单文件静态页**(`deploy/admin/index.html`,纯 HTML+CSS+JS,无需构建),nginx 独立 location 服务:
```nginx
location /admin/ { alias /opt/<app>/admin/; try_files $uri $uri/ /admin/index.html; }
```
- 同域调 `/app_03/api/admin/*`,token 存 localStorage。
### 6.3 访问埋点
- 模型 `PageView { openid, visit_id, path, duration_ms, created_at }`。
- 上报:`POST /api/events {visit_id, path, duration_ms}`(带用户 token)。
- 前端 `RootTracker`(包裹路由)监听 `useLocation` 变化与 `visibilitychange`,上报上一页停留时长;`visit_id` 用 `sessionStorage`(一次打开=一次访问)。仅登录后、停留 >0.2s 才上报。
- 后台聚合:按 openid 出「访问次数/总时长/最近活跃」,按 visit 还原「访问路径 + 每页时长」。
---
## 七、Nginx 配置模板(v2)
```nginx
server {
listen 80;
server_name mp.example.com;
location /.well-known/acme-challenge/ { root /opt/app_03/frontend/dist; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
server_name mp.example.com;
ssl_certificate /etc/letsencrypt/live/mp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mp.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# 独立后台(可选,放在 catch-all 之前)
location /admin/ { alias /opt/app_03/admin/; try_files $uri $uri/ /admin/index.html; }
# 无斜杠跳转:保留 query(避免丢 openid)
location = /app_03 { return 301 /app_03/$is_args$args; }
# 前端 SPA(相对资源 + history 回退)
location /app_03/ {
alias /opt/app_03/frontend/dist/;
try_files $uri $uri/ /app_03/index.html;
}
# API 代理(proxy_pass 带尾 /api/,前缀被替换)
location /app_03/api/ {
proxy_pass http://127.0.0.1:8030/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
> 证书用 `certbot certonly --webroot -w <dist目录> -d <域名>` 现签(复用已有 ACME 账号,无需邮箱)。
---
## 八、MongoDB 部署注意(v2 新增)
国内服务器优先**原生安装**,比 Docker 拉镜像稳:
- Docker Hub / 常见镜像源拉 `mongo` 大层常**卡死/0 速**(验证:`docker pull` 长时间无进度)。若必须 Docker,确认有可用国内 registry-mirror 且能拉 blob。
- **推荐原生 apt 装**(清华源稳):
```bash
echo "deb [trusted=yes] https://mirrors.tuna.tsinghua.edu.cn/mongodb/apt/ubuntu <codename>/mongodb-org/8.0 multiverse" \
> /etc/apt/sources.list.d/mongodb-org-8.0.list
apt-get update && apt-get install -y mongodb-org # 注意 codename 与版本匹配(如 noble→8.0)
```
- **安全基线**:`bindIp: 127.0.0.1`(仅本地)+ `security.authorization: enabled`,建 root 用户;连接串带 `?authSource=admin`。
- **每日备份**:`mongodump --gzip --archive` + cron,保留最近 N 份。
---
## 九、部署与运维
- 后端 PM2:`pm2 start .venv/bin/uvicorn --name <app> --interpreter none --cwd <backend> -- app.main:app --host 127.0.0.1 --port <port>`(`--interpreter none` 让 PM2 用 Python shebang,否则会当 Node 跑报错);`pm2 save` 持久化。
- 后端绑 `127.0.0.1`,由 nginx 反代,不对公网直接暴露端口。
- pip 用阿里源加速:`pip install -i https://mirrors.aliyun.com/pypi/simple/ -r requirements.txt`。
- 前端:`npm run build` → `rsync dist/` 到 `/opt/<app>/frontend/dist/`(nginx 直接服务静态,无需 reload)。
---
## 十、新建应用 Checklist(v2)
- [ ] 项目标识 snake_case;二级目录三处一致:Vite `base:'./'` / Router `basename:'/xxx'` / `VITE_API_PREFIX:'/xxx/api'`
- [ ] 生产 `.env.production` 设 `VITE_DEBUG=false`
- [ ] 后端 `.env`:MongoDB(带 authSource) / JWT / CRMEB / `DEV_MODE=0` / `ADMIN_PASS` 改默认 / `CORS_ORIGINS` 收紧
- [ ] 全站时区:UTC 存储 + 北京(UTC+8)分组/反推,前后端一致
- [ ] Nginx:`location = /xxx` 跳转**保留 query**;`/xxx/` 相对资源 + try_files;`/xxx/api/` 代理;(可选)`/admin/`
- [ ] MongoDB:原生安装 + localhost + 鉴权 + 每日备份 cron
- [ ] PM2 `--interpreter none` 启动后端 + `pm2 save`
- [ ] 小程序:业务域名白名单 + HTTPS + `/xxx/?openid=` 打开
- [ ] 后台 `/admin/` 可登录;前端埋点上报通;CRMEB openid/unionid 口径确认
- [ ] 真机 web-view 实测打开(带/不带斜杠都不丢 openid)
---
## 十一、从零起一个新应用 · 命令级 step-by-step
下面以新应用 `app_03`、端口 `8030`、域名 `mp.example.com`、二级目录 `/app_03/` 为例。
先定义变量(本地和服务器都可复用):
```bash
APP=app_03 # 应用标识(全小写 snake_case)
PORT=8030 # 后端端口(确认未占用)
DOMAIN=mp.example.com # 访问域名(已解析、能签 HTTPS)
DBNAME=$APP # Mongo 库名
SERVER=root@1.2.3.4 # 目标服务器
```
### 0. 本地建仓
```bash
mkdir -p ~/work/$APP && cd ~/work/$APP
git init -b main
printf "node_modules/\ndist/\n__pycache__/\n*.pyc\n.env\n.venv/\ntsconfig.tsbuildinfo\n" > .gitignore
mkdir -p backend/app/{routers,models,services,middleware} backend/tests frontend deploy/admin docs
```
### 1. 后端骨架
```bash
cd ~/work/$APP/backend
cat > requirements.txt <<'EOF'
fastapi==0.115.*
uvicorn[standard]==0.32.*
motor==3.6.*
beanie==1.27.*
pydantic-settings==2.6.*
PyJWT==2.10.*
httpx==0.27.*
pytest==8.3.*
pytest-asyncio==0.24.*
mongomock-motor==0.0.34
EOF
python3 -m venv .venv && . .venv/bin/activate
pip install -i https://mirrors.aliyun.com/pypi/simple/ -r requirements.txt
printf "[pytest]\nasyncio_mode = auto\npythonpath = .\n" > pytest.ini
# .env(按需改密钥)
cat > .env <<EOF
MONGODB_URL=mongodb://127.0.0.1:27017
MONGODB_DB_NAME=${DBNAME}_dev
JWT_SECRET_KEY=$(openssl rand -hex 32)
JWT_EXPIRE_DAYS=30
CRMEB_BASE_URL=https://shop.example.com
CRMEB_API_KEY=sk_change_me
HOST=0.0.0.0
PORT=${PORT}
DEV_MODE=1
ADMIN_USER=admin
ADMIN_PASS=admin888
CORS_ORIGINS=*
EOF
```
> 然后按第二~六节写 `config.py / db.py / models / routers / main.py`(可直接复制模板应用改标识)。
> 最小可跑:`main.py` 注册 `router`,`GET /api/health` 返回 `{"status":"ok"}`。
本地起后端(开发):
```bash
DEV_MODE=1 .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port $PORT --reload
# 另开终端验证
curl -s localhost:$PORT/api/health # {"status":"ok"}
pytest -q # 跑测试
```
### 2. 前端骨架
```bash
cd ~/work/$APP/frontend
npm create vite@latest . -- --template react-ts # 或手动建
npm i react-router-dom framer-motion recharts
npm i -D tailwindcss@3 postcss autoprefixer jsdom @testing-library/react vitest
npx tailwindcss init -p
```
关键配置(三处一致 + 相对资源):
```bash
# vite.config.ts: base: "./",dev proxy 见第 2.4 节
# routes.tsx: createBrowserRouter([...], { basename: "/app_03" })
# .env
printf "VITE_API_PREFIX=/app_03/api\nVITE_DEBUG=true\n" > .env
# .env.production(生产关调试)
printf "VITE_API_PREFIX=/app_03/api\nVITE_DEBUG=false\n" > .env.production
```
本地开发 / 构建:
```bash
npm run dev # 代理到后端 $PORT
npm run build # 产出 dist/(资源为 ./assets/...)
grep assets dist/index.html # 确认是 ./assets(相对)
```
### 3. 服务器:MongoDB(原生,首次装一次)
```bash
ssh $SERVER
CODENAME=$(. /etc/os-release; echo $VERSION_CODENAME) # 如 noble
echo "deb [trusted=yes] https://mirrors.tuna.tsinghua.edu.cn/mongodb/apt/ubuntu $CODENAME/mongodb-org/8.0 multiverse" \
> /etc/apt/sources.list.d/mongodb-org-8.0.list
apt-get update && apt-get install -y mongodb-org
systemctl enable --now mongod
# 建用户 + 开鉴权
MPWD=$(openssl rand -hex 24); echo "MONGO_PWD=$MPWD" # 记下来
mongosh --quiet --eval "db.getSiblingDB('admin').createUser({user:'$APP',pwd:'$MPWD',roles:[{role:'root',db:'admin'}]})"
grep -q 'authorization: enabled' /etc/mongod.conf || printf '\nsecurity:\n authorization: enabled\n' >> /etc/mongod.conf
systemctl restart mongod
```
### 4. 部署代码到服务器
```bash
# 本地:同步代码 + 构建产物
cd ~/work/$APP
rsync -az --exclude node_modules --exclude .venv --exclude __pycache__ --exclude dist \
backend deploy docs $SERVER:/opt/$APP/
rsync -az --delete frontend/dist/ $SERVER:/opt/$APP/frontend/dist/
# 服务器:建 venv + 写生产 .env(MONGODB_URL 用上一步的 MPWD)
ssh $SERVER bash -lc "
cd /opt/$APP/backend && python3 -m venv .venv &&
.venv/bin/pip install -i https://mirrors.aliyun.com/pypi/simple/ -r requirements.txt &&
sed -i 's#^MONGODB_URL=.*#MONGODB_URL=mongodb://$APP:<MPWD>@127.0.0.1:27017/$DBNAME?authSource=admin#;
s#^MONGODB_DB_NAME=.*#MONGODB_DB_NAME=$DBNAME#;
s#^DEV_MODE=.*#DEV_MODE=0#;
s#^CORS_ORIGINS=.*#CORS_ORIGINS=https://$DOMAIN#' .env
"
```
### 5. 服务器:PM2 起后端
```bash
ssh $SERVER bash -lc "
cd /opt/$APP/backend &&
pm2 start .venv/bin/uvicorn --name $APP --interpreter none --cwd /opt/$APP/backend -- app.main:app --host 127.0.0.1 --port $PORT &&
pm2 save &&
sleep 3 && curl -s localhost:$PORT/api/health
"
```
> `--interpreter none` 必加,否则 PM2 当 Node 跑 uvicorn 会报 ESM 错。
### 6. 服务器:Nginx + HTTPS
```bash
ssh $SERVER bash -lc "
# 先放一个 HTTP server 块(含 acme 与 /$APP/)做证书校验,再签证书
certbot certonly --webroot -w /opt/$APP/frontend/dist -d $DOMAIN --non-interactive --agree-tos --keep-until-expiring
"
# 然后写第七节的完整 vhost(含 location = /$APP 保留 query、/$APP/ 相对资源、/$APP/api/ 代理、可选 /admin/)
ssh $SERVER bash -lc "ln -sf /etc/nginx/sites-available/$APP /etc/nginx/sites-enabled/$APP && nginx -t && systemctl reload nginx"
```
### 7. 小程序接入
- 小程序后台「业务域名」加入 `https://$DOMAIN`(下载校验文件放到 dist 根,nginx 可访问)。
- web-view 打开:`https://$DOMAIN/$APP/?openid=<openid>`。
### 8. 上线验证(一键自检)
```bash
B=https://$DOMAIN
curl -s -o /dev/null -w "http跳转: %{http_code}\n" http://$DOMAIN/$APP/
curl -s -o /dev/null -w "首页: %{http_code}\n" $B/$APP/
curl -s $B/$APP/ | grep -o './assets/[^"]*\.js' | head -1 # 相对资源
curl -s -I "$B/$APP?openid=t1" | grep -i location # 应保留 ?openid=t1
curl -s $B/$APP/api/health # {"status":"ok"}
# 走一遍登录
TOK=$(curl -s -X POST $B/$APP/api/auth/openid-login -H 'Content-Type: application/json' -d '{"openid":"t1"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")
curl -s $B/$APP/api/user/profile -H "Authorization: Bearer $TOK"
```
### 9. 每日备份 cron(可选但推荐)
```bash
ssh $SERVER bash -lc "
cat > /opt/$APP/backup.sh <<'EOS'
#!/usr/bin/env bash
set -e; TS=\$(date +%Y%m%d-%H%M); OUT=/opt/$APP/backups; mkdir -p \$OUT
mongodump --uri='mongodb://$APP:<MPWD>@127.0.0.1:27017/$DBNAME?authSource=admin' --gzip --archive=\"\$OUT/$APP-\$TS.gz\"
ls -1t \$OUT/$APP-*.gz | tail -n +15 | xargs -r rm -f
EOS
chmod +x /opt/$APP/backup.sh
( crontab -l 2>/dev/null | grep -v $APP/backup.sh; echo '30 3 * * * /opt/$APP/backup.sh >> /var/log/$APP-backup.log 2>&1' ) | crontab -
"
```
### 日常更新(改完代码再次发布)
```bash
# 后端改动
rsync -az --exclude __pycache__ backend/app $SERVER:/opt/$APP/backend/
ssh $SERVER "pm2 restart $APP"
# 前端改动
npm run build && rsync -az --delete frontend/dist/ $SERVER:/opt/$APP/frontend/dist/
```
---
## 十二、WebView Bridge:分享 & 复制链接(v2 新增)
H5 页面在微信小程序 `<web-view>` 中运行,无法直接调用微信原生分享 API 或系统剪贴板。
需通过 **Mini Program Bridge** 模式实现:H5 发 `postMessage` → 小程序 `@message` 事件监听 → 小程序调用原生 API。
### 12.1 架构图
```
H5 (React SPA) 小程序 (uni-app web-view) 微信原生
┌─────────────────┐ ┌────────────────────┐ ┌─────────┐
│ 用户点"分享好友" │ │ │ │ │
│ bridge.share() │──postMessage────→│ @message │──调用──→ │ 分享面板 │
│ wx.miniProgram │ │ handleMessage() │ │ │
│ .postMessage() │ │ uni.share() │ └─────────┘
│ │ │ │ ┌─────────┐
│ 用户点"复制链接" │──postMessage────→│ @message │──调用──→ │ 剪贴板 │
│ bridge.copyLink()│ │ handleMessage() │ │ │
│ │ │ uni.setClipboard() │ └─────────┘
└─────────────────┘ └────────────────────┘
```
### 12.2 小程序侧:web-view 组件配置
在 `<web-view>` 上声明 `@message` 事件监听:
```html
<web-view :src="url" @message="handleMessage"></web-view>
```
实现 `handleMessage` 方法(uni-app / Vue 2 语法):
```javascript
handleMessage(e) {
const data = e.detail && e.detail.data ? e.detail.data[0] : null
if (!data) return
switch (data.action) {
case 'share':
uni.share({
provider: 'weixin',
scene: data.scene || 'WXSceneSession',
type: data.type || 0,
title: data.title || '',
href: data.href || this.url,
imageUrl: data.imageUrl || '',
summary: data.summary || '',
})
break
case 'copyLink':
uni.setClipboardData({
data: data.href || this.url,
success() { uni.showToast({ title: '链接已复制', icon: 'success' }) },
fail(err) { console.warn('[webview] 复制失败:', err) }
})
break
case 'setTitle':
if (data.title) uni.setNavigationBarTitle({ title: data.title })
break
}
}
```
### 12.3 小程序侧:原生"..."菜单分享
web-view 页面的右上角"..."菜单默认显示"转发/分享"为灰色不可点。
需要实现以下两个生命周期钩子使其可用:
```javascript
// 在 onLoad 中主动显示分享菜单
onLoad(option) {
// #ifdef MP
uni.showShareMenu({
menus: ['shareAppMessage', 'shareTimeline']
});
// #endif
}
// 处理转发给朋友
onShareAppMessage() {
return {
title: '页面标题',
path: '/pages/web-view-path?url=' + encodeURIComponent(this.url),
}
}
// 处理分享到朋友圈(微信限制:web-view 不支持,此钩子不会被触发)
onShareTimeline() {
return {
title: '页面标题',
query: 'url=' + encodeURIComponent(this.url)
}
}
```
> **已知限制**:`onShareTimeline`(分享到朋友圈)在 `<web-view>` 页面中**不支持**,微信 SDK 将其永久灰化。
> 替代方案:使用「生成分享图片 → 保存到相册 → 手动发朋友圈」。
### 12.4 H5 侧:bridge.ts 工具
在新应用的 `src/app/services/` 下创建 `bridge.ts`:
```typescript
/**
* 小程序 WebView 桥接工具
*/
declare const wx: any
function getMiniProgram() {
try {
if (typeof wx !== 'undefined' && wx.miniProgram) {
return wx.miniProgram
}
} catch { /* ignore */ }
return null
}
const bridge = {
postMessage(data: Record<string, any>) {
const mp = getMiniProgram()
if (mp) {
mp.postMessage({ data })
} else {
console.warn('[bridge] 不在小程序 web-view 环境中')
}
},
/** 分享给微信好友 / 朋友圈 */
share(opts: {
title: string
href?: string
summary?: string
imageUrl?: string
scene?: 'WXSceneSession' | 'WXSceneTimeline'
}) {
this.postMessage({
action: 'share',
title: opts.title || '',
href: opts.href || window.location.href,
summary: opts.summary || '',
imageUrl: opts.imageUrl || '',
scene: opts.scene || 'WXSceneSession',
})
},
/** 复制链接到剪贴板(通过 bridge 兜底) */
copyLink(link?: string) {
this.postMessage({
action: 'copyLink',
href: link || window.location.href,
})
},
/** 设置小程序导航栏标题 */
setTitle(title: string) {
if (!title) return
this.postMessage({ action: 'setTitle', title })
},
}
export default bridge
```
### 12.5 H5 侧:复制链接的正确方案
微信 web-view 中 `navigator.clipboard.writeText()` **不支持**,
需用 `document.execCommand('copy')` 作为主方案,bridge `postMessage` 作为兜底:
```typescript
async function handleCopyLink(url: string) {
let copied = false
const textArea = document.createElement('textarea')
textArea.value = url
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
textArea.style.opacity = '0'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
copied = document.execCommand('copy')
} catch (e) {
console.warn('[copy] execCommand failed:', e)
}
document.body.removeChild(textArea)
if (copied) {
setLinkCopied(true)
setTimeout(() => setLinkCopied(false), 2000)
} else {
// 兜底:通过 bridge postMessage(消息在页面离开时才触发)
bridge.copyLink(url)
// 乐观提示用户
setLinkCopied(true)
setTimeout(() => setLinkCopied(false), 2000)
}
}
```
### 12.6 postMessage 时序限制
| 问题 | 说明 |
|------|------|
| `wx.miniProgram.postMessage()` 消息不会立即送达 | 仅在**页面跳转/导航离开**时才会触发小程序的 `@message` 事件 |
| 不适合"立即复制"场景 | 点完按钮希望"立即复制到剪贴板"时,postMessage 无法实时响应 |
| **最终方案** | `execCommand('copy')` 做实时复制(主方案),postMessage 做兜底(用户离开页面时最终确认) |
### 12.7 微信 web-view 分享限制
| 分享方式 | 支持 | 备注 |
|---------|:----:|------|
| 转发给朋友("..."菜单) | ✅ | 需实现 `onShareAppMessage` + `uni.showShareMenu()` |
| 分享到朋友圈("..."菜单) | ❌ | **微信平台限制**,所有 web-view 页面均不支持 |
| H5 内部"分享给好友"按钮 | ✅ | 通过 `bridge.share()` → postMessage → `uni.share()` |
| H5 内部复制链接 | ✅ | `document.execCommand('copy')` 主方案 + bridge 兜底 |
| 生成分享图→存相册→手动发圈 | ✅ | 替代方案,需 `html-to-image` 等截图库 |
---
## 十三、新建应用 Checklist 补充(v2)
在原有 Checklist(第十节)基础上增加以下项:
- [ ] WebView Bridge: 小程序 web-view 加 `@message` + `handleMessage` 方法
- [ ] WebView Bridge: 实现 `onShareAppMessage()` + `uni.showShareMenu()` 启用原生"..."分享
- [ ] WebView Bridge: H5 端创建 `src/app/services/bridge.ts` 桥接工具
- [ ] WebView Bridge: H5 复制链接优先用 `document.execCommand('copy')`,bridge 兜底
- [ ] WebView Bridge: 确认微信 JS-SDK 已加载(`jweixin-1.2.0.js`),否则 `wx.miniProgram` 不可用
- [ ] WebView Bridge: 生成分享图片场景使用 `html-to-image` 等截图库
- [ ] H5 身份一致性: 路由守卫中比对 URL openid 与缓存 token openid,不一致时清除缓存重新登录