606 lines
18 KiB
Markdown
606 lines
18 KiB
Markdown
# H5 小程序嵌入式应用开发模板
|
||
|
||
> 适用于嵌入微信小程序 web-view 的 H5 应用
|
||
> 技术栈:FastAPI + MongoDB (Beanie) + React 18 + React Router 7 + Vite 6
|
||
> 认证完全依赖小程序侧 JWT
|
||
|
||
---
|
||
|
||
## 一、项目架构
|
||
|
||
### 1.1 技术栈
|
||
|
||
| 层 | 技术 | 备注 |
|
||
|---|------|------|
|
||
| 前端 | React 18 + React Router 7 + Vite 6 | SPA,独立 basename |
|
||
| UI 库 | 自选(MUI / shadcn/ui / TailwindCSS) | |
|
||
| 状态 | React Context + localStorage 持久化 | 无 Redux/Zustand |
|
||
| 后端 | FastAPI + Uvicorn | |
|
||
| 数据库 | MongoDB (Motor + Beanie ODM) | |
|
||
| 认证 | JWT(外部传入,不验证签名) | python-jose |
|
||
|
||
### 1.2 目录结构
|
||
|
||
```
|
||
project/
|
||
├── frontend/
|
||
│ ├── src/app/
|
||
│ │ ├── pages/ # 页面组件
|
||
│ │ ├── components/ # 通用组件
|
||
│ │ ├── components/ui/ # 基础 UI 组件(shadcn/ui 风格)
|
||
│ │ ├── context/ # React Context
|
||
│ │ ├── services/ # API 服务
|
||
│ │ └── data/ # 静态数据
|
||
│ │ ├── routes.tsx # 路由配置
|
||
│ │ └── App.tsx # 根组件
|
||
│ ├── package.json
|
||
│ └── dist/ # 构建产物
|
||
├── backend/
|
||
│ ├── app/
|
||
│ │ ├── routers/ # API 路由模块
|
||
│ │ ├── models/ # Beanie 数据模型
|
||
│ │ ├── services/ # 业务逻辑层
|
||
│ │ ├── middleware/ # 认证中间件
|
||
│ │ └── config.py # 配置
|
||
│ ├── requirements.txt
|
||
│ ├── serv.sh # 服务管理脚本
|
||
│ └── .env # 环境变量
|
||
└── docs/
|
||
└── development-standards.md
|
||
```
|
||
|
||
### 1.3 多应用同域名部署架构
|
||
|
||
```
|
||
同一个 Nginx 下代理多个 H5 应用,每个应用独立后端进程:
|
||
|
||
https://mp.yizhezhe.com/
|
||
├── /app_03/ → 孕育无忧 H5
|
||
│ ├── → index.html(前端 SPA,basename=/app_03)
|
||
│ └── /api/ → proxy_pass localhost:8030
|
||
├── /baby_food/ → 婴儿辅食 H5
|
||
│ ├── → index.html(前端 SPA,basename=/baby_food)
|
||
│ └── /api/ → proxy_pass localhost:8031
|
||
└── /album/ → 宝宝相册 H5
|
||
├── → index.html(前端 SPA,basename=/album)
|
||
└── /api/ → proxy_pass localhost:8032
|
||
```
|
||
|
||
每个应用是一个独立项目(独立 repo 或独立目录),拥有自己的:
|
||
- 前端构建产物(`dist/`)
|
||
- 后端服务进程(不同端口)
|
||
- 数据库(不同 MongoDB database)
|
||
- `.env` 和环境变量
|
||
|
||
### 1.4 Nginx 配置模板
|
||
|
||
```nginx
|
||
server {
|
||
listen 443 ssl;
|
||
server_name mp.yizhezhe.com;
|
||
|
||
# ─── 应用: app_03 ───
|
||
location = /app_03 { return 301 /app_03/; }
|
||
location /app_03/ {
|
||
alias /www/app_03/frontend/dist/;
|
||
try_files $uri $uri/ /app_03/index.html;
|
||
}
|
||
location /app_03/api/ {
|
||
rewrite ^/app_03(/api/.*)$ $1 break;
|
||
proxy_pass http://127.0.0.1:8030;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
}
|
||
|
||
# ─── 应用: baby_food ───
|
||
location = /baby_food { return 301 /baby_food/; }
|
||
location /baby_food/ {
|
||
alias /www/baby_food/frontend/dist/;
|
||
try_files $uri $uri/ /baby_food/index.html;
|
||
}
|
||
location /baby_food/api/ {
|
||
rewrite ^/baby_food(/api/.*)$ $1 break;
|
||
proxy_pass http://127.0.0.1:8031;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
}
|
||
}
|
||
```
|
||
|
||
> location = /xxx 必须带,否则 /app_03 无尾随斜杠时返回 404。
|
||
|
||
### 1.5 新建应用步骤
|
||
|
||
1. 复制模板目录,重命名项目
|
||
2. 修改 `backend/app/config.py` → `app_name`, `mongodb_db_name`, `port`
|
||
3. 修改前端 basename(Vite `base` + React Router `basename`)
|
||
4. 定义数据模型(`backend/app/models/`)
|
||
5. 编写业务 API(`backend/app/routers/`)
|
||
6. 编写前端页面 + 路由
|
||
7. 添加 Nginx location 块
|
||
8. 启动验证
|
||
|
||
---
|
||
|
||
## 二、认证规范
|
||
|
||
### 2.1 认证流程
|
||
|
||
```
|
||
小程序 → {token(JWT)} → URL params → H5 LoginPage
|
||
↓
|
||
decodeToken(JWT) 提取 uid/openid/phone
|
||
↓
|
||
localStorage 存储 token + user_basic
|
||
↓
|
||
后端 API 请求带 Authorization: Bearer
|
||
↓
|
||
get_current_user 解码 JWT → 按 openid 查找/创建用户
|
||
```
|
||
|
||
### 2.2 JWT 格式
|
||
|
||
**前端 decodeToken() 解析出的字段:**
|
||
```typescript
|
||
interface TokenPayload {
|
||
uid: number; // 小程序侧的用户 ID
|
||
openid: string; // 微信 openid
|
||
phone: string; // 手机号(可能为空)
|
||
exp: number; // 过期时间戳
|
||
}
|
||
```
|
||
|
||
**后端自签 token(dev-login 兼容,同格式):**
|
||
```json
|
||
{ "uid": "...", "openid": "...", "phone": "...", "exp": ..., "iss": "localhost" }
|
||
```
|
||
|
||
### 2.3 认证中间件规则
|
||
|
||
```python
|
||
# middleware/auth.py
|
||
async def get_current_user(credentials: HTTPAuthorizationCredentials) -> User:
|
||
payload = jwt.decode(token, None, options={"verify_signature": False, "verify_exp": False})
|
||
openid = payload.get("openid") # 必须存在
|
||
# 手动验证 exp 过期
|
||
user = await User.find_one(User.openid == openid)
|
||
if not user:
|
||
user = User(openid=openid, phone=phone or None)
|
||
await user.insert()
|
||
else:
|
||
# 有 phone 且变化时更新
|
||
return user
|
||
```
|
||
|
||
- 不验证 JWT 签名(`verify_signature: False`),信任小程序侧
|
||
- 手动验证 `exp` 过期
|
||
- 提取 `openid` → 按 openid 查找/创建用户
|
||
- `create_access_token()` 保持原样,兼容旧逻辑
|
||
|
||
### 2.4 DEV_MODE
|
||
|
||
- 环境变量 `DEV_MODE=1` 启用开发模式
|
||
- 仅 `/api/auth/dev-login` 受此控制
|
||
- 测试账号固定 openid,在浏览器开发时自动登录
|
||
- 启动方式:`DEV_MODE=1 uvicorn app.main:app --port 8030 --reload`
|
||
|
||
### 2.5 前端登录流程(LoginPage.tsx 模板)
|
||
|
||
```typescript
|
||
const params = new URLSearchParams(window.location.search);
|
||
const tokenFromUrl = params.get('token');
|
||
|
||
if (tokenFromUrl) {
|
||
const payload = decodeToken(tokenFromUrl);
|
||
setTokenAndUser(tokenFromUrl, {
|
||
id: String(payload.uid),
|
||
openid: payload.openid,
|
||
phone: payload.phone,
|
||
});
|
||
// 拉取用户数据 → 跳转首页或引导页
|
||
fetchProfile().then((dto) => {
|
||
navigate(dto?.is_onboarded ? "/home" : "/onboarding", { replace: true });
|
||
});
|
||
} else if (isWechatMiniProgram()) {
|
||
// 通知小程序传递 token
|
||
window.wx?.miniProgram?.postMessage({ type: "NEED_TOKEN" });
|
||
} else {
|
||
// 浏览器开发环境 → dev-login
|
||
devLogin().then(res => setTokenAndUser(res.token, { ... }));
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 三、前端规范
|
||
|
||
### 3.1 API 调用
|
||
|
||
```typescript
|
||
// services/api.ts
|
||
const TOKEN_KEY = "auth_token";
|
||
|
||
export function getToken(): string | null {
|
||
return localStorage.getItem(TOKEN_KEY);
|
||
}
|
||
|
||
export function setToken(token: string) {
|
||
localStorage.setItem(TOKEN_KEY, token);
|
||
}
|
||
|
||
export function clearToken() {
|
||
localStorage.removeItem(TOKEN_KEY);
|
||
}
|
||
|
||
/** 通用 API 请求封装 */
|
||
async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||
const token = getToken();
|
||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||
|
||
const res = await fetch(apiPath(path), { ...options, headers });
|
||
if (res.status === 401) { clearToken(); window.location.href = APP_BASE_URL; }
|
||
if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.detail || `请求失败 (${res.status})`); }
|
||
return res.json();
|
||
}
|
||
```
|
||
|
||
**新增 API 的步骤:**
|
||
1. 在 `api.ts` 定义接口(`export interface XxxResponse`)
|
||
2. 定义函数(`export async function xxx(): Promise<T>`)
|
||
3. 在页面中 import 调用
|
||
|
||
### 3.2 路由与守卫
|
||
|
||
```typescript
|
||
// routes.tsx
|
||
function ProtectedRoute() {
|
||
const token = getToken();
|
||
const urlToken = new URLSearchParams(window.location.search).get('token');
|
||
if (urlToken) {
|
||
return <Navigate to={`/?token=${encodeURIComponent(urlToken)}`} replace />;
|
||
}
|
||
if (!token) {
|
||
return <Navigate to={`/?redirect=${encodeURIComponent(location.pathname)}`} replace />;
|
||
}
|
||
return <MobileLayout />;
|
||
}
|
||
```
|
||
|
||
路由结构:
|
||
```
|
||
/ → LoginPage(无保护)
|
||
/onboarding → 引导页(登录后首次使用)
|
||
/home → 首页(保护)
|
||
/xxx → 其他业务页面(保护)
|
||
```
|
||
|
||
### 3.3 状态管理(Context 模板)
|
||
|
||
```typescript
|
||
// context/UserContext.tsx
|
||
export interface BasicUserInfo {
|
||
id: string;
|
||
openid: string;
|
||
phone: string;
|
||
}
|
||
|
||
interface UserContextValue {
|
||
user: UserProfile; // 由具体业务定义
|
||
token: string | null;
|
||
setTokenAndUser: (token: string, info: BasicUserInfo) => void;
|
||
fetchProfile: () => Promise<void>;
|
||
logout: () => void;
|
||
// + 业务自定义方法
|
||
}
|
||
```
|
||
|
||
- `BasicUserInfo` (`id`/`openid`/`phone`) 持久化到 `localStorage['user_basic']`
|
||
- 用户业务数据持久化到各自 key
|
||
- 切换用户时自动清理旧数据
|
||
|
||
### 3.4 组件规范
|
||
|
||
- 页面组件统一放在 `pages/`,命名 `XxxPage.tsx`
|
||
- 通用业务组件在 `components/`
|
||
- 基础 UI 组件在 `components/ui/`(shadcn/ui 风格,按需引入)
|
||
- 函数式组件 + Hooks,class 组件禁用
|
||
- `react-hooks/exhaustive-deps` 可 eslint-disable(mount-once 模式)
|
||
- 动画推荐 `framer-motion`(包名 `motion`),图标推荐 `lucide-react`
|
||
|
||
### 3.5 编码风格
|
||
|
||
- 中文注释
|
||
- `// ─── Section ───` 大段视觉分隔
|
||
- 不需要过度的错误处理,完整 traceback 就是调试信息
|
||
- 复杂逻辑封装到工具类/服务层,页面保持精简
|
||
|
||
### 3.6 前端路径规范(多应用部署)
|
||
|
||
每个 H5 应用的 basename 由两部分确定:
|
||
|
||
| 配置项 | 位置 | 值 |
|
||
|--------|------|----|
|
||
| Vite `base` | `frontend/vite.config.ts` | `'/app_03/'` |
|
||
| React Router `basename` | `routes.tsx` → `createBrowserRouter(routes, {basename: '/app_03'})` | `'/app_03'` |
|
||
| fetch API 前缀 | `api.ts` → `APP_BASE_URL` | `'/app_03'` |
|
||
|
||
**basename 命名规范:** `/{项目标识}`,全小写 + 下划线(`snake_case`)。
|
||
|
||
**配置示例(vite.config.ts):**
|
||
```typescript
|
||
export default defineConfig({
|
||
base: '/app_03/', // 构建产物路径前缀
|
||
plugins: [react()],
|
||
server: { port: 5173 },
|
||
})
|
||
```
|
||
|
||
**配置示例(routes.tsx):**
|
||
```typescript
|
||
const router = createBrowserRouter(routes, {
|
||
basename: '/app_03',
|
||
});
|
||
```
|
||
|
||
**api.ts 中的 base URL 自动拼接:**
|
||
```typescript
|
||
const API_PREFIX = import.meta.env.VITE_API_PREFIX || "/api";
|
||
// APP_BASE_URL = basename = 从 API_PREFIX 提取("/app_03/api" → "/app_03")
|
||
const APP_BASE_URL = API_PREFIX.replace(/\/api\/?$/, '') || '/';
|
||
|
||
function joinPrefix(prefix: string, path: string) {
|
||
const p = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
|
||
const s = path.startsWith('/') ? path : `/${path}`;
|
||
return `${p}${s}`;
|
||
}
|
||
// 最终请求: /app_03/api/xxx
|
||
```
|
||
|
||
**.env 配置:**
|
||
```ini
|
||
# API_PREFIX 必须带 basename,开发和 Nginx 代理一致
|
||
VITE_API_PREFIX=/app_03/api
|
||
```
|
||
|
||
**Vite proxy 配置(开发环境):**
|
||
```typescript
|
||
// vite.config.ts
|
||
server: {
|
||
proxy: {
|
||
[apiPrefix]: { // "/app_03/api"
|
||
target: 'http://localhost:8030',
|
||
changeOrigin: true,
|
||
rewrite: (path) => path.replace(/^\/app_03\/api/, '/api'),
|
||
},
|
||
},
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 四、后端规范
|
||
|
||
### 4.1 路由与 API 前缀规范
|
||
|
||
每个应用的 API 通过 Nginx 统一代理,路径结构为 `/{basename}/api/*`。
|
||
|
||
| 应用 | 前端路径 | API 路径 | 后端端口 |
|
||
|------|---------|---------|---------|
|
||
| 孕育无忧 | `/app_03/` | `/app_03/api/*` | 8030 |
|
||
| 婴儿辅食 | `/baby_food/` | `/baby_food/api/*` | 8031 |
|
||
| 宝宝相册 | `/album/` | `/album/api/*` | 8032 |
|
||
|
||
**后端路由注册:**
|
||
```python
|
||
# config.py
|
||
api_prefix: str = "/api" # 后端内部的 API 前缀,与前端 APP_BASE_URL 一致
|
||
|
||
# routers/__init__.py — 统一注册
|
||
router = APIRouter(prefix="/api")
|
||
router.include_router(auth.router) # auth.router 前缀: /api/auth/xxx
|
||
router.include_router(user.router) # user.router 前缀: /api/user/xxx
|
||
|
||
# 单个路由模块定义(routers/user.py)
|
||
router = APIRouter(prefix="/api/user", tags=["user"])
|
||
```
|
||
|
||
> Nginx 的 `rewrite ^/app_03(/api/.*)$ $1 break` 会将 `/app_03/api/xxx` 剥离为 `/api/xxx` 转发到后端,所以后端不感知外部路径前缀。
|
||
|
||
**路由文件结构:**
|
||
```python
|
||
routers/
|
||
├── __init__.py # 统一注册所有路由
|
||
├── auth.py # 认证:exchange-code, dev-login
|
||
├── user.py # 用户 CRUD
|
||
└── *.py # 按业务模块拆分
|
||
```
|
||
|
||
### 4.2 数据库命名规范
|
||
|
||
```python
|
||
# config.py
|
||
mongodb_db_name: str = "app_03" # 数据库名 = 应用标识,与 basename 一致
|
||
```
|
||
|
||
| 应用 | 数据库名 | 说明 |
|
||
|------|---------|------|
|
||
| 孕育无忧 | `app_03` | 应用目录名 / 项目名 |
|
||
| 婴儿辅食 | `baby_food` | 全小写 snake_case |
|
||
| 宝宝相册 | `album` | 简短标识 |
|
||
|
||
**规范:**
|
||
- 全小写 + 下划线(`snake_case`)
|
||
- 与前端 basename 保持一致(去掉首斜杠)
|
||
- 与项目目录名保持一致
|
||
- 避免用复数(`users` → ❌, `app_03` → ✅)
|
||
- 同一 MongoDB 实例下的所有应用数据库平级,无交叉数据
|
||
|
||
**models 目录结构(每个应用独立):**
|
||
```python
|
||
models/
|
||
├── __init__.py # 导出所有模型
|
||
└── user.py # User Document
|
||
# 业务模型按需新增文件,不跨应用复用
|
||
```
|
||
|
||
### 4.3 数据模型(Beanie ODM 模板)
|
||
|
||
```python
|
||
class User(Document):
|
||
openid: str = Field(..., unique=True) # 微信 openid,用户唯一标识
|
||
session_key: Optional[str] = None
|
||
union_id: Optional[str] = None
|
||
name: str = ""
|
||
phone: Optional[str] = None
|
||
avatar_url: Optional[str] = None
|
||
is_onboarded: bool = False # 是否完成引导
|
||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||
```
|
||
|
||
> **注意:** 业务模型按需新增,User 仅作为认证底座,业务数据可新建 Document 或嵌入 User。
|
||
|
||
### 4.4 API 响应格式
|
||
|
||
```python
|
||
# 成功 — 直接返回 Pydantic Model 或 dict
|
||
# 错误 — HTTPException(status_code, detail="中文描述")
|
||
```
|
||
|
||
### 4.5 服务管理 (serv.sh)
|
||
|
||
```bash
|
||
# .env 中配置:
|
||
APP_NAME=app_name
|
||
APP_MODULE=app.main:app
|
||
HOST=0.0.0.0
|
||
PORT=8000
|
||
DEV_MODE=1
|
||
|
||
# 命令:
|
||
./serv.sh start [--reload] # 启动
|
||
./serv.sh stop # 停止
|
||
./serv.sh restart [--reload] # 重启
|
||
./serv.sh status # 状态
|
||
./serv.sh logs [YYYY-MM-DD] # 日志
|
||
```
|
||
|
||
---
|
||
|
||
## 五、代码模板
|
||
|
||
### 5.1 新增业务页面
|
||
|
||
```typescript
|
||
import { useEffect, useState } from "react";
|
||
import { useNavigate } from "react-router";
|
||
import { useUser } from "../context/UserContext";
|
||
import { fetchApi } from "../services/api";
|
||
|
||
export function XxxPage() {
|
||
const navigate = useNavigate();
|
||
const { user, token } = useUser();
|
||
const [data, setData] = useState(null);
|
||
const [error, setError] = useState("");
|
||
|
||
useEffect(() => {
|
||
fetchApi("/xxx").then(setData).catch(e => setError(e.message));
|
||
}, []);
|
||
|
||
if (error) return <div className="text-red-500 p-4">{error}</div>;
|
||
if (!data) return <div className="p-4">加载中...</div>;
|
||
|
||
return <div>{/* 业务内容 */}</div>;
|
||
}
|
||
```
|
||
|
||
### 5.2 新增 API 接口
|
||
|
||
```typescript
|
||
// api.ts
|
||
export interface XxxDTO {
|
||
id: string;
|
||
// ... 业务字段
|
||
}
|
||
|
||
export async function getXxxList(): Promise<XxxDTO[]> {
|
||
return fetchApi<XxxDTO[]>("/xxx");
|
||
}
|
||
|
||
export async function createXxx(data: Record<string, unknown>): Promise<XxxDTO> {
|
||
return fetchApi<XxxDTO>("/xxx", { method: "POST", body: JSON.stringify(data) });
|
||
}
|
||
```
|
||
|
||
### 5.3 后端路由模板
|
||
|
||
```python
|
||
from fastapi import APIRouter, Depends
|
||
from app.middleware.auth import get_current_user
|
||
from app.models.user import User
|
||
|
||
router = APIRouter(prefix="/api/xxx", tags=["xxx"])
|
||
|
||
@router.get("/")
|
||
async def list_items(user: User = Depends(get_current_user)):
|
||
# user.openid / user.id 可用
|
||
return {"items": [...]}
|
||
|
||
@router.post("/")
|
||
async def create_item(data: ..., user: User = Depends(get_current_user)):
|
||
return {"id": "...", ...}
|
||
```
|
||
|
||
---
|
||
|
||
## 六、安全注意事项
|
||
|
||
1. JWT 不验证签名 — 信任链依赖于小程序的环境隔离,H5 本身不做额外验证
|
||
2. DEV_MODE 仅控制 dev-login — 生产环境不设此变量
|
||
3. CORS 配置 `cors_origins: *` — 开发环境宽松,生产应限制
|
||
4. 微信 session_key 存入数据库但不对外暴露
|
||
|
||
---
|
||
|
||
## 七、环境变量 (.env 模板)
|
||
|
||
```ini
|
||
# MongoDB
|
||
MONGODB_URL=mongodb://localhost:27017
|
||
MONGODB_DB_NAME=app_name
|
||
|
||
# JWT
|
||
JWT_SECRET_KEY=change-this-secret-key
|
||
JWT_ALGORITHM=HS256
|
||
JWT_EXPIRE_MINUTES=10080
|
||
|
||
# WeChat
|
||
WECHAT_APP_ID=
|
||
WECHAT_APP_SECRET=
|
||
|
||
# Server
|
||
HOST=0.0.0.0
|
||
PORT=8000
|
||
DEBUG=true
|
||
DEV_MODE=1
|
||
|
||
# CORS
|
||
CORS_ORIGINS=*
|
||
```
|
||
|
||
---
|
||
|
||
## 八、初始化新应用 Checklist
|
||
|
||
- [ ] 定义项目标识(`app_03`/`baby_food`/`album` 等),保持一致
|
||
- [ ] 复制模板 → 修改 `backend/app/config.py` → `app_name`, `mongodb_db_name`, `port`
|
||
- [ ] 修改前端 basename:Vite `base` + React Router `basename` + `api.ts` 的 `APP_BASE_URL`
|
||
- [ ] 设置 WeChat AppID/Secret(生产环境)
|
||
- [ ] 更换 `JWT_SECRET_KEY`
|
||
- [ ] 定义业务数据模型(`backend/app/models/`)
|
||
- [ ] 编写业务 API 路由(`backend/app/routers/`)
|
||
- [ ] 创建前端页面 + 路由配置
|
||
- [ ] 添加 Nginx location 块(前端 SPA + API 代理)
|
||
- [ ] 启动验证:`DEV_MODE=1` → 浏览器访问 → dev-login → 首页
|