Files
app_03_pregnancy/docs/development-standards.md

17 KiB
Raw Blame History

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 配置模板

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() 解析出的字段:

interface TokenPayload {
  uid: number;         // 小程序侧的用户 ID
  openid: string;      // 微信 openid
  phone: string;       // 手机号(可能为空)
  exp: number;         // 过期时间戳
}

后端自签 token(dev-login 兼容,同格式):

{ "uid": "...", "openid": "...", "phone": "...", "exp": ..., "iss": "localhost" }

2.3 认证中间件规则

# 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 模板)

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 调用

// 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 路由与守卫

// 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 模板)

// 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):

export default defineConfig({
  base: '/app_03/',              // 构建产物路径前缀
  plugins: [react()],
  server: { port: 5173 },
})

配置示例(routes.tsx):

const router = createBrowserRouter(routes, {
  basename: '/app_03',
});

api.ts 中的 base URL 自动拼接:

const APP_BASE_URL = '/app_03';   // 由环境变量 VITE_APP_BASE_URL 控制
function joinPath(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

四、后端规范

4.1 路由与 API 前缀规范

每个应用的 API 通过 Nginx 统一代理,路径结构为 /{basename}/api/*。

应用 前端路径 API 路径 后端端口
孕育无忧 /app_03/ /app_03/api/* 8030
婴儿辅食 /baby_food/ /baby_food/api/* 8031
宝宝相册 /album/ /album/api/* 8032

后端路由注册:

# 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 转发到后端,所以后端不感知外部路径前缀。

路由文件结构:

routers/
├── __init__.py         # 统一注册所有路由
├── auth.py             # 认证:exchange-code, dev-login
├── user.py             # 用户 CRUD
└── *.py                # 按业务模块拆分

4.2 数据库命名规范

# config.py
mongodb_db_name: str = "app_03"   # 数据库名 = 应用标识,与 basename 一致
应用 数据库名 说明
孕育无忧 app_03 应用目录名 / 项目名
婴儿辅食 baby_food 全小写 snake_case
宝宝相册 album 简短标识

规范:

  • 全小写 + 下划线(snake_case)
  • 与前端 basename 保持一致(去掉首斜杠)
  • 与项目目录名保持一致
  • 避免用复数(users → ❌, app_03 → ✅)
  • 同一 MongoDB 实例下的所有应用数据库平级,无交叉数据

models 目录结构(每个应用独立):

models/
├── __init__.py     # 导出所有模型
└── user.py         # User Document
# 业务模型按需新增文件,不跨应用复用

4.3 数据模型(Beanie ODM 模板)

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 响应格式

# 成功 — 直接返回 Pydantic Model 或 dict
# 错误 — HTTPException(status_code, detail="中文描述")

4.5 服务管理 (serv.sh)

# .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 新增业务页面

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 接口

// 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 后端路由模板

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 模板)

# 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 → 首页