Files
app_03_pregnancy/docs/development-standards.md

606 lines
18 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 小程序嵌入式应用开发模板
> 适用于嵌入微信小程序 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 → 首页