From 201f80811a6f766a6aac6532e319deeaf4d8d022 Mon Sep 17 00:00:00 2001 From: Karlcarlo Date: Thu, 28 May 2026 16:08:26 +0800 Subject: [PATCH] refactor: update development standards and enhance ProfilePage with emergency contact editing --- docs/CHANGELOG.md | 178 ++++++++ docs/crmeb-internal-api-plan.md | 247 +++++++++++ docs/development-standards.md | 562 ++++++++----------------- frontend/src/app/pages/ProfilePage.tsx | 149 +++++-- 4 files changed, 724 insertions(+), 412 deletions(-) create mode 100644 docs/CHANGELOG.md create mode 100644 docs/crmeb-internal-api-plan.md diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..2e9e0bb --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,178 @@ +# 孕育无忧 H5 应用修改记录 + +> 项目: app_03 (孕育无忧 — H5 小程序嵌入式) +> 时间跨度: 2026-05-21 ~ 2026-05-27 +> 技术栈: FastAPI + MongoDB + React 18 + Vite 6 + +--- + +## 一、认证流程演进(4 次重构) + +### Phase 1: JWT 直传(80904e6 ~ 1eef3ae) + +**方案:** 小程序 `?token=JWT` → H5 decodeToken → 后端 verify_signature=False 按 openid 查/创建用户 + +**关键改动:** +- 后端 `get_current_user` 取消签名验证 +- JWT payload 字段调整为 `{uid, openid, phone}` +- 前端 `decodeToken()` 解析 token +- 无需小程序手动传递 uid,仅 token 即可 + +**问题:** 小程序端 web-view URL 中 `openid=` 为空值 → 走 dev-login 分支 → 数据库记录 name='开发测试用户' + +### Phase 2: 参数直传(1eef3ae) + +**方案:** `?uid=&openid=&phone=` → 前端 `createDummyToken()` 构建本地 JWT → 直接登录 + +**关键改动:** +- 新增 `createDummyToken()` 函数,前端构造 base64 编码的 JWT +- JWT base64url 解码需 `replace(/-/g,'+').replace(/_/g,'/')` +- 新增 `/auth/params-login` 后端接口(可选,暂不需要) +- 移除 paramsLogin API 调用,纯前端完成 + +**问题:** URL 传 3 个参数太繁琐,小程序端需要额外处理手机号敏感信息 + +### Phase 3: 统一调试(150dafc ~ bceb4f4) + +**新增功能:** +- `services/debug.ts` — 统一调试模块:`isDebug()`, `debugLog()`, `getDebugLogs()` +- 通过 `.env` 中 `VITE_DEBUG=true` 控制,开发默认开启,生产关闭 +- LoginPage 自动记录登录流程;ProfilePage 按条件显示调试面板 +- 头像支持:后端 `UserProfileUpdate` 新增 `avatar_url` 字段 +- ProfilePage 头像卡片显示真实头像(有 avatar_url 时) +- ProfilePage 增加用户姓名编辑字段 + +### Phase 4: CRMEB 代理(bceb4f4 ~ 96a938e,当前版本) + +**方案:** `?openid=xxx`(单参数)→ 后端 `openid-login` → 调 CRMEB 内部接口获取信息 → 自动补齐 + +**关键改动(96a938e):** + +后端: +- 新增 `POST /auth/openid-login` — 接收 openid,调 CRMEB 获取 nickname/phone/avatar +- `config.py` 新增 `crmeb_base_url`、`crmeb_api_key` +- 查找/创建用户时自动同步:name、phone、avatar_url + +前端: +- LoginPage 简化:检测 openid → 调 openid-login → 跳转 +- UserContext 修复 avatar_url 未映射导致头像不显示 + +CRMEB 端(外部): +- 新增 `InternalController`,`GET /api/internal/user-info?openid=xxx` +- `X-Internal-Key` Header 认证,硬编码密钥(后续改为 config 读取) + +--- + +## 二、完整 Commit 清单 + +| SHA | 时间 | 类型 | 摘要 | +|-----|------|------|------| +| `96a938e` | 05/27 22:24 | feat | **openid-login + avatar_url 修复** — CRMEB 代理登录方案最终版 | +| `bceb4f4` | 05/27 20:24 | feat | 支持从小程序获取微信头像和昵称同步到 H5 | +| `150dafc` | 05/27 20:19 | feat | 统一调试模块 debug.ts,VITE_DEBUG 环境变量控制 | +| `27b5856` | 05/27 18:52 | cleanup | 移除所有调试代码,登录页精简 | +| `1eef3ae` | 05/27 18:48 | refactor | 小程序参数直传 uid/openid/phone,前端构建本地 token | +| `8c50afc` | 05/27 03:44 | feat | 小程序 web-view Token 传递问题分析与修复方案 | +| `6dc6258` | 05/27 03:07 | feat | H5 登录调试面板,URL 信息显示 | +| `5a0f1f4` | 05/27 02:27 | feat | API 前缀配置调整,Vite 代理更新 | +| `fe116b9` | 05/27 02:19 | feat | 调试面板 | +| `80904e6` | 05/27 01:49 | feat | 按 openid 查找/创建用户,调整 JWT 负载字段 | +| `3506731` | 05/23 02:15 | fix | 修复 DEV_MODE 解析 | +| `08df182` | 05/23 01:59 | refactor | 简化 H5 应用用户授权 | +| `53128a6` | 05/22 00:51 | feat | 添加 serv.sh 服务管理脚本 | +| `ded7ea1` | 05/21 20:56 | feat | 新增可配置路径前缀支持子路径部署 | +| `fd1aea1` | 05/21 17:10 | build | 配置部署子路径 /app_03/ | +| `409daba` | 05/21 00:46 | feat | 初始化项目基础代码结构 | + +--- + +## 三、新增/修改文件清单 + +### 新增文件 + +| 文件 | 目的 | +|------|------| +| `frontend/src/app/services/debug.ts` | 统一调试模块:日志记录/读取/清除 | +| `backend/serv.sh` | 服务管理脚本(start/stop/restart/logs) | +| `docs/crmeb-internal-api-plan.md` | CRMEB 内部接口方案文档 | +| `.env.example` | 环境变量模板 | + +### 关键修改文件 + +| 文件 | 变化 | +|------|------| +| `frontend/src/app/pages/LoginPage.tsx` | 3 次重写:token 解码 → 参数直传 → openid-login | +| `frontend/src/app/pages/ProfilePage.tsx` | 添加头像显示、姓名编辑、调试面板 | +| `frontend/src/app/services/api.ts` | 添加 fetchApi export、createDummyToken | +| `frontend/src/app/context/UserContext.tsx` | 修复 avatar_url 映射 | +| `frontend/.env` | 添加 VITE_API_PREFIX、VITE_DEBUG、VITE_UPLOADS_PREFIX | +| `backend/app/config.py` | 添加 crmeb_base_url、crmeb_api_key | +| `backend/app/routers/auth.py` | 添加 openid-login 接口、dev-login 调整 | +| `backend/app/schemas/user.py` | UserProfileUpdate 添加 avatar_url | +| `backend/app/middleware/auth.py` | verify_signature=False | +| `backend/app/models/user.py` | 添加 avatar_url 字段 | + +--- + +## 四、部署与运维记录 + +### 服务器信息 + +| 项目 | 地址 | +|------|------| +| app_03 服务 | 8.145.39.61:8030(uvicorn) | +| CRMEB 服务 | 8.130.191.152:20199(Swoole) | +| H5 入口 | https://mp.yizhezhe.com/app_03/ | +| Git 远程 | gitea@git@git@gitea.nexjs.com:jone/app_03_pregnancy.git | + +### 部署关键点 + +1. **Nginx 尾随斜杠问题**:`location = /app_03 { return 301 /app_03/; }` 必须带,否则丢 query 参数 +2. **API 代理**:前后端统一使用 `/app_03/api/*` 路径,Nginx rewrite 剥离前缀 +3. **后端重启命令**: + ```bash + fuser -k 8030/tcp && sleep 2 && \ + nohup env DEV_MODE=1 .venv/bin/uvicorn app.main:app \ + --host 127.0.0.1 --port 8030 --workers 1 > app.log 2>&1 & + ``` +4. **前端构建**: + ```bash + cd frontend && npm run build + scp -r dist/* root@8.145.39.61:/opt/app/app_03_pregnancy/frontend/dist/ + ``` + +### 服务器 Git 管理 + +- 服务器仓库 reset 到 1eef3ae(合并前状态),remote 指向 gitea +- 回滚后 HEAD 在 96a938e,状态 clean +- 本地实现新功能后再 push 到 gitea,服务器 pull 部署 + +--- + +## 五、测试账号 + +| 角色 | openid | 手机号 | 昵称 | +|------|--------|--------|------| +| 真实用户 | `oauWC1zc7hVn3j60QwkuHyjGn03Y` | 13693687880 | 枫逸 | +| 开发测试 | `dev_test_account_001` | 13800000000 | 开发测试用户 | + +--- + +## 六、废弃方案记录 + +| 方案 | 原因 | commit | +|------|------|--------| +| JWT 签名验证 | 小程序 token 无法共享签名密钥,改为不验签 | — | +| jwt-yun `exchange-code` | 外部服务不可用 | — | +| URL 传 3 参数 (`?uid=&openid=&phone=`) | 参数繁琐,敏感信息暴露 | 1eef3ae | +| paramsLogin 后端接口 | 纯前端可完成,不需要额外 API | 1eef3ae | +| 前端 createDummyToken() | 替代为后端 openid-login 返回 token | bceb4f4 | + +--- + +## 七、待办项 + +- [ ] CRMEB InternalController 密钥从 config 读取(当前硬编码) +- [ ] 小程序端 web-view URL 改为 `?openid=` 单参格式 +- [ ] 前后端代码 commit + push 到 gitea +- [ ] 服务器 deploy 最新代码 diff --git a/docs/crmeb-internal-api-plan.md b/docs/crmeb-internal-api-plan.md new file mode 100644 index 0000000..10caf60 --- /dev/null +++ b/docs/crmeb-internal-api-plan.md @@ -0,0 +1,247 @@ +# CRMEB 后端新增内部接口方案 + +## 一、背景 + +当前 H5 登录需要小程序端传多个 URL 参数: + +``` +?uid=7&openid=oauWC...&phone=13693687880&name=王小明&avatar=https://... +``` + +改为只传 `openid`,由 app_03 后端主动向 CRMEB 查询用户信息。 + +--- + +## 二、方案架构 + +``` +H5: ?openid=xxx + ↓ +app_03 后端 → GET /api/internal/user-info?openid=xxx → CRMEB 后端 + ← { nickname, phone, avatar, uid } + ↓ +创建/更新 User → 返回 token → 登录完成 +``` + +--- + +## 三、CRMEB 端改动 + +### 3.1 新增控制器 + +`app/controller/api/v1/InternalController.php`: + +```php +request->header('X-Internal-Key'); + $configKey = sys_config('internal_api_key', ''); + if (!$apiKey || $apiKey !== $configKey) { + return json(['code' => 403, 'msg' => 'Forbidden']); + } + + // 2. 获取 openid 参数 + $openid = $this->request->param('openid', ''); + if (!$openid) { + return json(['code' => 400, 'msg' => 'openid is required']); + } + + // 3. 查询微信用户信息 + $wechatUser = $wechatUserServices->getOne(['openid' => $openid]); + if (!$wechatUser) { + return json(['code' => 404, 'msg' => 'user not found']); + } + + // 4. 获取用户主表信息(nickname, phone, avatar 可能在用户表或微信表) + $uid = $wechatUser['uid']; + $userServices = app()->make(\app\services\user\UserServices::class); + $user = $userServices->getUserInfo($uid); + + return json([ + 'code' => 200, + 'data' => [ + 'uid' => $uid, + 'nickname' => $user['nickname'] ?? $wechatUser['nickname'] ?? '', + 'phone' => $user['phone'] ?? $wechatUser['phone'] ?? '', + 'avatar' => $user['avatar'] ?? $wechatUser['headimgurl'] ?? '', + ], + ]); + } +} +``` + +### 3.2 新增路由 + +`config/route.php` 或 `route/app.php` 中增加: + +```php +// 内部接口(无需用户登录验证) +Route::get('api/internal/user-info', 'api/v1.Internal/userInfo'); +``` + +> CRMEB 的路由文件通常在 `route/app.php`、`route/api.php` 或 `route/v1.php` 中,根据实际项目结构添加。 + +### 3.3 配置内部分享密钥 + +在 CRMEB 后台 → 系统设置 → 系统配置 中添加配置项 `internal_api_key`: + +``` +键名: internal_api_key +键值: 生成一个随机字符串,如:sk_crmeb_app03_xxxxxxxx +说明: 内部服务间通信密钥 +``` + +或者在 `.env` 中直接添加: + +```ini +INTERNAL_API_KEY=sk_crmeb_app03_xxxxxxxx +``` + +然后在代码中读取: + +```php +$configKey = env('INTERNAL_API_KEY', ''); +``` + +### 3.4 验证 WechatUserServices 是否有 getOne 方法 + +```php +// 如果没有,WechatUserServices 中需添加 +public function getOne(array $where): array +{ + return $this->dao->getOne($where); +} +``` + +--- + +## 四、app_03 后端改动 + +### 4.1 `.env` 新增配置 + +```ini +CRMEB_BASE_URL=https://your-crmeb-domain.com +CRMEB_API_KEY=sk_crmeb_app03_xxxxxxxx +``` + +### 4.2 `config.py` 新增字段 + +```python +class Settings(BaseSettings): + # ... 原有字段 + crmeb_base_url: str = "" + crmeb_api_key: str = "" +``` + +### 4.3 新增 `routers/auth.py` 接口 + +```python +class OpenidRequest(BaseModel): + openid: str + + +@router.post("/openid-login") +async def openid_login(req: OpenidRequest) -> TokenResponse: + """仅传 openid → 调 CRMEB 获取用户信息 → 创建/更新 User → 返回 token""" + # 1. 调 CRMEB 内部接口 + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{settings.crmeb_base_url}/api/internal/user-info", + params={"openid": req.openid}, + headers={"X-Internal-Key": settings.crmeb_api_key}, + ) + data = resp.json() + + if data.get("code") != 200: + raise HTTPException(status_code=400, detail="获取用户信息失败") + + info = data["data"] + + # 2. 查找或创建用户 + user = await User.find_one(User.openid == req.openid) + if not user: + user = User( + openid=req.openid, + name=info.get("nickname", "") or "", + phone=info.get("phone") or None, + avatar_url=info.get("avatar") or None, + ) + await user.insert() + else: + user.name = info.get("nickname", "") or user.name + if info.get("phone"): + user.phone = info["phone"] + if info.get("avatar"): + user.avatar_url = info["avatar"] + await user.save() + + # 3. 返回 token + token = _build_token(user) + return TokenResponse( + token=token, + user_id=str(user.id), + openid=user.openid, + mobile=user.phone or "", + ) +``` + +### 4.4 H5 前端 LoginPage + +```typescript +// 检测 openid 参数 → 调后端 openid-login +const openidParam = params.get('openid'); +if (openidParam && openidParam.trim()) { + const res = await fetchApi("/auth/openid-login", { + method: "POST", + body: JSON.stringify({ openid: openidParam }), + }); + setTokenAndUser(res.token, { id: res.user_id, openid: res.openid, phone: res.mobile }); + // fetchProfile 自动拿到 name/avatar_url +} +``` + +--- + +## 五、部署步骤 + +| 步骤 | 操作 | 负责 | +|------|------|------| +| 1 | CRMEB 加 `InternalController` | 小程序端 | +| 2 | CRMEB 加路由 | 小程序端 | +| 3 | CRMEB 配置 `internal_api_key` | 小程序端 | +| 4 | app_03 `.env` 加 `CRMEB_BASE_URL` + `CRMEB_API_KEY` | H5 端 | +| 5 | app_03 `config.py` 加字段 | H5 端 | +| 6 | app_03 加 `openid-login` 接口 | H5 端 | +| 7 | H5 前端 LoginPage 改为调 `openid-login` | H5 端 | + +--- + +## 六、最终效果 + +小程序端 web-view URL 简化为一行: + +```javascript +// 之前(5个参数) +const url = 'https://mp.yizhezhe.com/app_03/?uid=' + uid + '&openid=' + encodeURIComponent(openid) + '&phone=' + phone + '&name=' + encodeURIComponent(name) + '&avatar=' + encodeURIComponent(avatar); + +// 之后(1个参数) +const url = 'https://mp.yizhezhe.com/app_03/?openid=' + encodeURIComponent(openid); +``` diff --git a/docs/development-standards.md b/docs/development-standards.md index 97f88e2..7637a6e 100644 --- a/docs/development-standards.md +++ b/docs/development-standards.md @@ -2,7 +2,7 @@ > 适用于嵌入微信小程序 web-view 的 H5 应用 > 技术栈:FastAPI + MongoDB (Beanie) + React 18 + React Router 7 + Vite 6 -> 认证完全依赖小程序侧 JWT +> 认证:小程序传 openid → 后端调 CRMEB 接口获取用户信息 → 自动补齐昵称/头像 --- @@ -13,11 +13,11 @@ | 层 | 技术 | 备注 | |---|------|------| | 前端 | React 18 + React Router 7 + Vite 6 | SPA,独立 basename | -| UI 库 | 自选(MUI / shadcn/ui / TailwindCSS) | | +| UI 库 | MUI 7 + shadcn/ui (Radix) + TailwindCSS 4 | | | 状态 | React Context + localStorage 持久化 | 无 Redux/Zustand | | 后端 | FastAPI + Uvicorn | | | 数据库 | MongoDB (Motor + Beanie ODM) | | -| 认证 | JWT(外部传入,不验证签名) | python-jose | +| 认证 | openid → 后端调 CRMEB 内部接口 | 无 JWT 签名验证 | ### 1.2 目录结构 @@ -29,7 +29,7 @@ project/ │ │ ├── components/ # 通用组件 │ │ ├── components/ui/ # 基础 UI 组件(shadcn/ui 风格) │ │ ├── context/ # React Context -│ │ ├── services/ # API 服务 +│ │ ├── services/ # API 服务 + debug 工具 │ │ └── data/ # 静态数据 │ │ ├── routes.tsx # 路由配置 │ │ └── App.tsx # 根组件 @@ -55,23 +55,14 @@ project/ 同一个 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 +├── /app_03/ → 前端 SPA(basename=/app_03) +│ └── /app_03/api/ → proxy_pass localhost:8030/api/ +├── /baby_food/ → 前端 SPA +│ └── /baby_food/api/ → proxy_pass localhost:8031/api/ +└── /album/ → 前端 SPA + └── /album/api/ → proxy_pass localhost:8032/api/ ``` -每个应用是一个独立项目(独立 repo 或独立目录),拥有自己的: -- 前端构建产物(`dist/`) -- 后端服务进程(不同端口) -- 数据库(不同 MongoDB database) -- `.env` 和环境变量 - ### 1.4 Nginx 配置模板 ```nginx @@ -86,8 +77,7 @@ server { 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_pass http://127.0.0.1:8030/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } @@ -99,26 +89,24 @@ server { 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_pass http://127.0.0.1:8031/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } } ``` -> location = /xxx 必须带,否则 /app_03 无尾随斜杠时返回 404。 +> `location = /app_03` 处理无尾随斜杠的 301 跳转。 +> `location /app_03/api/` proxy_pass 带尾斜杠 = 匹配部分被完整替换。 ### 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. 启动验证 +1. 定义项目标识(全小写 snake_case,如 `baby_food`) +2. 复制模板 → 修改 `config.py` → `app_name`, `mongodb_db_name`, `port` +3. 修改前端 basename:Vite `base` + React Router `basename` + `APP_BASE_URL` +4. 添加 Nginx location 块(前端 + API 代理) +5. 定义数据模型 → 编写 API → 编写前端页面 +6. 启动验证 --- @@ -127,113 +115,102 @@ server { ### 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 查找/创建用户 +小程序 URL: ?openid=oauWC1zc7hVn3j60QwkuHyjGn03Y + ↓ +H5 LoginPage → 前端无 token 处理 + ↓ + POST /api/auth/openid-login { openid } + ↓ + app_03 后端 → GET CRMEB /api/internal/user-info?openid=xxx + ↓ ↓ (X-Internal-Key 验证) + { nickname, phone, avatar } + ↓ + 创建/更新 User(MongoDB) + ↓ + 返回自签 JWT token + ↓ + setTokenAndUser → fetchProfile → 跳转首页/引导页 ``` -### 2.2 JWT 格式 +### 2.2 登录流程(LoginPage.tsx) -**前端 decodeToken() 解析出的字段:** ```typescript -interface TokenPayload { - uid: number; // 小程序侧的用户 ID - openid: string; // 微信 openid - phone: string; // 手机号(可能为空) - exp: number; // 过期时间戳 +const openidParam = params.get('openid'); +if (openidParam && openidParam.trim()) { + const res = await fetchApi("/auth/openid-login", { + method: "POST", + body: JSON.stringify({ openid: openidParam }), + }); + setTokenAndUser(res.token, { id: res.user_id, openid: res.openid, phone: res.mobile }); + // fetchProfile 自动拿到 name/avatar_url(已从 CRMEB 同步) } ``` -**后端自签 token(dev-login 兼容,同格式):** -```json -{ "uid": "...", "openid": "...", "phone": "...", "exp": ..., "iss": "localhost" } +**小程序端 URL 格式:** +```javascript +const url = 'https://mp.yizhezhe.com/app_03/?openid=' + encodeURIComponent(openid); ``` -### 2.3 认证中间件规则 +### 2.3 后端 openid-login 接口(routers/auth.py) ```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) +@router.post("/openid-login") +async def openid_login(req: OpenidRequest) -> TokenResponse: + # 1. 调 CRMEB 内部接口获取用户信息 + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{settings.crmeb_base_url}/api/internal/user-info", + params={"openid": req.openid}, + headers={"X-Internal-Key": settings.crmeb_api_key}, + ) + info = resp.json()["data"] + + # 2. 查找或创建用户(昵称/手机号/头像自动同步) + user = await User.find_one(User.openid == req.openid) if not user: - user = User(openid=openid, phone=phone or None) + user = User(openid=req.openid, name=info["nickname"], ...) await user.insert() else: - # 有 phone 且变化时更新 + user.name = info["nickname"]; user.phone = info["phone"] + await user.save() + + return TokenResponse(token=_build_token(user), ...) +``` + +### 2.4 认证中间件(middleware/auth.py) + +```python +async def get_current_user(credentials) -> 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(openid, phone) + else: 更新 phone 等 return user ``` -- 不验证 JWT 签名(`verify_signature: False`),信任小程序侧 +- 不验证 JWT 签名(`verify_signature: False`),信任自身生成或小程序传入的 token - 手动验证 `exp` 过期 -- 提取 `openid` → 按 openid 查找/创建用户 -- `create_access_token()` 保持原样,兼容旧逻辑 +- 按 openid 查找/创建用户 -### 2.4 DEV_MODE +### 2.5 DEV_MODE - 环境变量 `DEV_MODE=1` 启用开发模式 -- 仅 `/api/auth/dev-login` 受此控制 -- 测试账号固定 openid,在浏览器开发时自动登录 +- 仅 `POST /api/auth/dev-login` 受此控制 +- 测试账号:openid=`dev_test_account_001`, phone=`13800000000` - 启动方式:`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 调用 +### 3.1 API 调用(services/api.ts) ```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(path: string, options: RequestInit = {}): Promise { +/** 通用 API 请求封装(已 export) */ +export async function fetchApi(path: string, options: RequestInit = {}): Promise { const token = getToken(); const headers: Record = { "Content-Type": "application/json" }; if (token) headers["Authorization"] = `Bearer ${token}`; @@ -247,22 +224,32 @@ async function fetchApi(path: string, options: RequestInit = {}): Promise **新增 API 的步骤:** 1. 在 `api.ts` 定义接口(`export interface XxxResponse`) -2. 定义函数(`export async function xxx(): Promise`) +2. 定义函数(`export async function xxx(): Promise`),内部调 `fetchApi()` 3. 在页面中 import 调用 -### 3.2 路由与守卫 +### 3.2 调试模块(services/debug.ts) + +```typescript +// VITE_DEBUG=true 时启用调试日志和面板 +export function isDebug(): boolean +export function debugLog(msg: string): void // 写入 localStorage +export function getDebugLogs(): string[] // 读取日志 +export function clearDebugLogs(): void +``` + +- 由 `.env` 中 `VITE_DEBUG=true` 控制 +- 开发环境默认开启,生产环境设为 false 关闭 +- LoginPage 自动记录登录流程 +- ProfilePage 显示「展开调试日志」按钮 + +### 3.3 路由与守卫 ```typescript // routes.tsx function ProtectedRoute() { const token = getToken(); - const urlToken = new URLSearchParams(window.location.search).get('token'); - if (urlToken) { - return ; - } - if (!token) { - return ; - } + // 无 token → Navigate to /?redirect=当前路径 + if (!token) return ; return ; } ``` @@ -275,100 +262,39 @@ function ProtectedRoute() { /xxx → 其他业务页面(保护) ``` -### 3.3 状态管理(Context 模板) +### 3.4 状态管理(UserContext) ```typescript -// context/UserContext.tsx -export interface BasicUserInfo { +interface BasicUserInfo { id: string; openid: string; phone: string; } - -interface UserContextValue { - user: UserProfile; // 由具体业务定义 - token: string | null; - setTokenAndUser: (token: string, info: BasicUserInfo) => void; - fetchProfile: () => Promise; - logout: () => void; - // + 业务自定义方法 -} ``` -- `BasicUserInfo` (`id`/`openid`/`phone`) 持久化到 `localStorage['user_basic']` -- 用户业务数据持久化到各自 key +- `BasicUserInfo` 持久化到 `localStorage['user_basic']` - 切换用户时自动清理旧数据 -### 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 由两部分确定: +### 3.5 前端路径规范 | 配置项 | 位置 | 值 | |--------|------|----| -| 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'` | +| Vite `base` | `vite.config.ts` | `'/app_03/'` | +| React Router `basename` | `routes.tsx` | `'/app_03'` | +| `VITE_API_PREFIX` | `.env` | `'/app_03/api'` | -**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 配置:** +**`.env` 配置:** ```ini -# API_PREFIX 必须带 basename,开发和 Nginx 代理一致 VITE_API_PREFIX=/app_03/api +VITE_UPLOADS_PREFIX=/uploads +VITE_DEBUG=true ``` -**Vite proxy 配置(开发环境):** +**Vite proxy(开发环境):** ```typescript -// vite.config.ts server: { proxy: { - [apiPrefix]: { // "/app_03/api" + [apiPrefix]: { // "/app_03/api" target: 'http://localhost:8030', changeOrigin: true, rewrite: (path) => path.replace(/^\/app_03\/api/, '/api'), @@ -377,229 +303,109 @@ server: { } ``` +### 3.6 组件规范 + +- 页面组件 `pages/XxxPage.tsx`,业务组件 `components/` +- 基础 UI 组件 `components/ui/`(shadcn/ui 风格) +- 函数式组件 + Hooks,中文注释 +- 动画用 `motion`(framer-motion),图标用 `lucide-react` + --- ## 四、后端规范 -### 4.1 路由与 API 前缀规范 +### 4.1 路由与 API 前缀 -每个应用的 API 通过 Nginx 统一代理,路径结构为 `/{basename}/api/*`。 +| 应用 | API 路径 | 后端端口 | +|------|---------|---------| +| 孕育无忧 | `/app_03/api/*` | 8030 | +| 婴儿辅食 | `/baby_food/api/*` | 8031 | -| 应用 | 前端路径 | 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 — 统一注册 +# 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"]) +router.include_router(auth.router) # /api/auth/xxx +router.include_router(user.router) # /api/user/xxx ``` -> Nginx 的 `rewrite ^/app_03(/api/.*)$ $1 break` 会将 `/app_03/api/xxx` 剥离为 `/api/xxx` 转发到后端,所以后端不感知外部路径前缀。 +### 4.2 数据库命名 -**路由文件结构:** -```python -routers/ -├── __init__.py # 统一注册所有路由 -├── auth.py # 认证:exchange-code, dev-login -├── user.py # 用户 CRUD -└── *.py # 按业务模块拆分 +``` +app_03 / baby_food / album # 全小写 snake_case = 项目标识 ``` -### 4.2 数据库命名规范 +同一 MongoDB 实例下各应用数据库平级,不交叉。 -```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 模板) +### 4.3 数据模型 ```python class User(Document): - openid: str = Field(..., unique=True) # 微信 openid,用户唯一标识 - session_key: Optional[str] = None - union_id: Optional[str] = None + openid: str = Field(..., unique=True) # 用户唯一标识 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)) + is_onboarded: bool = False ``` -> **注意:** 业务模型按需新增,User 仅作为认证底座,业务数据可新建 Document 或嵌入 User。 +> 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
{error}
; - if (!data) return
加载中...
; - - return
{/* 业务内容 */}
; -} -``` - -### 5.2 新增 API 接口 - -```typescript -// api.ts -export interface XxxDTO { - id: string; - // ... 业务字段 -} - -export async function getXxxList(): Promise { - return fetchApi("/xxx"); -} - -export async function createXxx(data: Record): Promise { - return fetchApi("/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 模板) +### 4.4 `.env` 完整配置 ```ini # MongoDB MONGODB_URL=mongodb://localhost:27017 -MONGODB_DB_NAME=app_name +MONGODB_DB_NAME=app_03_dev # JWT JWT_SECRET_KEY=change-this-secret-key -JWT_ALGORITHM=HS256 -JWT_EXPIRE_MINUTES=10080 -# WeChat -WECHAT_APP_ID= -WECHAT_APP_SECRET= +# CRMEB 内部接口 +CRMEB_BASE_URL=https://shop.douba.top +CRMEB_API_KEY=sk_... # Server HOST=0.0.0.0 -PORT=8000 -DEBUG=true +PORT=8030 DEV_MODE=1 - -# CORS -CORS_ORIGINS=* ``` --- -## 八、初始化新应用 Checklist +## 五、CRMEB 内部接口规范 -- [ ] 定义项目标识(`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 → 首页 +### 5.1 接口定义 + +``` +GET /api/internal/user-info?openid=xxx +Header: X-Internal-Key: xxx +``` + +### 5.2 响应格式 + +```json +{ + "code": 200, + "data": { + "uid": 7, + "nickname": "枫逸", + "phone": "13693687880", + "avatar": "https://..." + } +} +``` + +### 5.3 认证方式 + +服务间共享密钥,通过 `X-Internal-Key` Header 传递。密钥存入 CRMEB 配置文件或系统设置。 + +--- + +## 六、初始化新应用 Checklist + +- [ ] 定义项目标识,三处保持一致:Vite base / basename / 数据库名 +- [ ] 复制模板 → 修改 `config.py` → `app_name`、`mongodb_db_name`、`port` +- [ ] 配置 `.env`:MongoDB / JWT / CRMEB(如果有)/ CORS +- [ ] 添加 Nginx location 块(`location = /xxx` + `location /xxx/` + `location /xxx/api/`) +- [ ] 定义业务数据模型 + 编写 API + 编写前端页面 +- [ ] 启动后端 → 构建前端 → 部署 → 验证 diff --git a/frontend/src/app/pages/ProfilePage.tsx b/frontend/src/app/pages/ProfilePage.tsx index cfab072..3949983 100644 --- a/frontend/src/app/pages/ProfilePage.tsx +++ b/frontend/src/app/pages/ProfilePage.tsx @@ -18,7 +18,7 @@ import { AlertCircle, Shield, Bell, - LogOut, + // LogOut, // 已隐藏退出按钮 } from "lucide-react"; import { Button } from "../components/ui/button"; import { Card } from "../components/ui/card"; @@ -79,6 +79,8 @@ export function ProfilePage() { }); const [editValues, setEditValues] = useState>({}); + // 紧急联系人子字段独立编辑(嵌套对象,用路径做 isEditing key) + const [emergencySubField, setEmergencySubField] = useState(null); const handleEdit = (field: string) => { setIsEditing(field); @@ -109,12 +111,40 @@ export function ProfilePage() { const handleCancel = () => { setIsEditing(null); setEditValues({}); + setEmergencySubField(null); }; + // 紧急联系人子字段编辑 + const handleEmergencyEdit = (subField: string) => { + setEmergencySubField(subField); + setEditValues({}); + }; + const handleEmergencySave = (subField: string, value: string) => { + const updated = { ...userInfo, emergencyContact: { ...userInfo.emergencyContact, [subField]: value } }; + setUserInfo(updated); + setEmergencySubField(null); + updateUserRemote({ emergencyContact: updated.emergencyContact }); + }; + + // 紧急联系人单行输入组件 + function EmergencyInput({ value, onSave, onCancel }: { value: string; onSave: (v: string) => void; onCancel: () => void }) { + const [v, setV] = useState(value); + return ( + setV(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') onSave(v); if (e.key === 'Escape') onCancel(); }} + className="mt-1 px-2 py-1 border border-morandi-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-morandi-500" + autoFocus + /> + ); + } + const calculateBMI = () => { + if (!userInfo.height || !userInfo.currentWeight) return "--"; const heightInMeters = userInfo.height / 100; const bmi = userInfo.currentWeight / (heightInMeters * heightInMeters); - return bmi.toFixed(1); + return isNaN(bmi) ? "--" : bmi.toFixed(1); }; const calculateWeightGain = () => { @@ -467,46 +497,97 @@ export function ProfilePage() { 紧急联系人 -
-
+ {/* 紧急联系人-姓名 */} +
+
+
+ +
姓名
-
- {userInfo.emergencyContact.name} -
-
- - {userInfo.emergencyContact.relation} - -
-
-
联系电话
-
- {userInfo.emergencyContact.phone} + {emergencySubField === 'name' ? ( + handleEmergencySave('name', v)} + onCancel={() => setEmergencySubField(null)} + /> + ) : ( +
{userInfo.emergencyContact.name}
+ )}
+ {emergencySubField === 'name' ? ( +
+ + +
+ ) : ( + + )} +
+ {/* 紧急联系人-关系 */} +
+
+
+ +
+
+
关系
+ {emergencySubField === 'relation' ? ( + handleEmergencySave('relation', v)} + onCancel={() => setEmergencySubField(null)} + /> + ) : ( + + {userInfo.emergencyContact.relation || "未设置"} + + )} +
+
+ {emergencySubField === 'relation' ? ( +
+ + +
+ ) : ( + + )} +
+ {/* 紧急联系人-电话 */} +
+
+
+ +
+
+
联系电话
+ {emergencySubField === 'phone' ? ( + handleEmergencySave('phone', v)} + onCancel={() => setEmergencySubField(null)} + /> + ) : ( +
{userInfo.emergencyContact.phone || "未设置"}
+ )} +
+
+ {emergencySubField === 'phone' ? ( +
+ + +
+ ) : ( + + )}
- {/* 退出登录 */} - - - + {/* 退出登录 — 已隐藏,保留 logout 引用避免 lint 警告 */} + {null}