refactor: update development standards and enhance ProfilePage with emergency contact editing
This commit is contained in:
178
docs/CHANGELOG.md
Normal file
178
docs/CHANGELOG.md
Normal file
@@ -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 最新代码
|
||||
247
docs/crmeb-internal-api-plan.md
Normal file
247
docs/crmeb-internal-api-plan.md
Normal file
@@ -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
|
||||
<?php
|
||||
|
||||
namespace app\controller\api\v1;
|
||||
|
||||
use app\BaseController;
|
||||
use app\services\wechat\WechatUserServices;
|
||||
use think\Response;
|
||||
|
||||
class InternalController extends BaseController
|
||||
{
|
||||
/**
|
||||
* 内部接口:根据 openid 获取用户信息
|
||||
* 用于 app_03 H5 登录时自动补齐用户数据
|
||||
*
|
||||
* @param WechatUserServices $wechatUserServices
|
||||
* @return Response
|
||||
*/
|
||||
public function userInfo(WechatUserServices $wechatUserServices): Response
|
||||
{
|
||||
// 1. 验证内部 API Key
|
||||
$apiKey = $this->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);
|
||||
```
|
||||
@@ -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<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
/** 通用 API 请求封装(已 export) */
|
||||
export 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}`;
|
||||
@@ -247,22 +224,32 @@ async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T>
|
||||
|
||||
**新增 API 的步骤:**
|
||||
1. 在 `api.ts` 定义接口(`export interface XxxResponse`)
|
||||
2. 定义函数(`export async function xxx(): Promise<T>`)
|
||||
2. 定义函数(`export async function xxx(): Promise<T>`),内部调 `fetchApi<T>()`
|
||||
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 <Navigate to={`/?token=${encodeURIComponent(urlToken)}`} replace />;
|
||||
}
|
||||
if (!token) {
|
||||
return <Navigate to={`/?redirect=${encodeURIComponent(location.pathname)}`} replace />;
|
||||
}
|
||||
// 无 token → Navigate to /?redirect=当前路径
|
||||
if (!token) return <Navigate to={`/?redirect=...`} replace />;
|
||||
return <MobileLayout />;
|
||||
}
|
||||
```
|
||||
@@ -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<void>;
|
||||
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 <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 模板)
|
||||
### 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 + 编写前端页面
|
||||
- [ ] 启动后端 → 构建前端 → 部署 → 验证
|
||||
|
||||
@@ -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<Partial<UserInfo>>({});
|
||||
// 紧急联系人子字段独立编辑(嵌套对象,用路径做 isEditing key)
|
||||
const [emergencySubField, setEmergencySubField] = useState<string | null>(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 (
|
||||
<input
|
||||
value={v}
|
||||
onChange={(e) => 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() {
|
||||
紧急联系人
|
||||
</h2>
|
||||
<Card className="bg-white rounded-2xl p-5 shadow-sm border-0">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 紧急联系人-姓名 */}
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-morandi-100 rounded-lg flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-morandi-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">姓名</div>
|
||||
<div className="font-semibold text-gray-800">
|
||||
{userInfo.emergencyContact.name}
|
||||
</div>
|
||||
</div>
|
||||
<Badge className="bg-morandi-sage-100 text-morandi-sage-600 hover:bg-morandi-sage-100">
|
||||
{userInfo.emergencyContact.relation}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">联系电话</div>
|
||||
<div className="font-semibold text-gray-800">
|
||||
{userInfo.emergencyContact.phone}
|
||||
{emergencySubField === 'name' ? (
|
||||
<EmergencyInput
|
||||
value={userInfo.emergencyContact.name}
|
||||
onSave={(v) => handleEmergencySave('name', v)}
|
||||
onCancel={() => setEmergencySubField(null)}
|
||||
/>
|
||||
) : (
|
||||
<div className="font-semibold text-gray-800">{userInfo.emergencyContact.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{emergencySubField === 'name' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => handleEmergencySave('name', (document.getElementById('emg-name') as HTMLInputElement)?.value || userInfo.emergencyContact.name)} className="w-8 h-8 bg-morandi-sage-100 rounded-full flex items-center justify-center hover:bg-morandi-sage-200"><Check className="w-4 h-4 text-morandi-sage-500" /></button>
|
||||
<button onClick={() => setEmergencySubField(null)} className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center hover:bg-gray-200"><X className="w-4 h-4 text-gray-600" /></button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => handleEmergencyEdit('name')} className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center hover:bg-gray-200"><Edit2 className="w-4 h-4 text-gray-600" /></button>
|
||||
)}
|
||||
</div>
|
||||
{/* 紧急联系人-关系 */}
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-morandi-100 rounded-lg flex items-center justify-center">
|
||||
<Heart className="w-4 h-4 text-morandi-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">关系</div>
|
||||
{emergencySubField === 'relation' ? (
|
||||
<EmergencyInput
|
||||
value={userInfo.emergencyContact.relation}
|
||||
onSave={(v) => handleEmergencySave('relation', v)}
|
||||
onCancel={() => setEmergencySubField(null)}
|
||||
/>
|
||||
) : (
|
||||
<Badge className="bg-morandi-sage-100 text-morandi-sage-600 hover:bg-morandi-sage-100 font-semibold">
|
||||
{userInfo.emergencyContact.relation || "未设置"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{emergencySubField === 'relation' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => handleEmergencySave('relation', (document.getElementById('emg-relation') as HTMLInputElement)?.value || userInfo.emergencyContact.relation)} className="w-8 h-8 bg-morandi-sage-100 rounded-full flex items-center justify-center hover:bg-morandi-sage-200"><Check className="w-4 h-4 text-morandi-sage-500" /></button>
|
||||
<button onClick={() => setEmergencySubField(null)} className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center hover:bg-gray-200"><X className="w-4 h-4 text-gray-600" /></button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => handleEmergencyEdit('relation')} className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center hover:bg-gray-200"><Edit2 className="w-4 h-4 text-gray-600" /></button>
|
||||
)}
|
||||
</div>
|
||||
{/* 紧急联系人-电话 */}
|
||||
<div className="flex items-center justify-between py-3 last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-morandi-100 rounded-lg flex items-center justify-center">
|
||||
<Phone className="w-4 h-4 text-morandi-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">联系电话</div>
|
||||
{emergencySubField === 'phone' ? (
|
||||
<EmergencyInput
|
||||
value={userInfo.emergencyContact.phone}
|
||||
onSave={(v) => handleEmergencySave('phone', v)}
|
||||
onCancel={() => setEmergencySubField(null)}
|
||||
/>
|
||||
) : (
|
||||
<div className="font-semibold text-gray-800">{userInfo.emergencyContact.phone || "未设置"}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{emergencySubField === 'phone' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => handleEmergencySave('phone', (document.getElementById('emg-phone') as HTMLInputElement)?.value || userInfo.emergencyContact.phone)} className="w-8 h-8 bg-morandi-sage-100 rounded-full flex items-center justify-center hover:bg-morandi-sage-200"><Check className="w-4 h-4 text-morandi-sage-500" /></button>
|
||||
<button onClick={() => setEmergencySubField(null)} className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center hover:bg-gray-200"><X className="w-4 h-4 text-gray-600" /></button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => handleEmergencyEdit('phone')} className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center hover:bg-gray-200"><Edit2 className="w-4 h-4 text-gray-600" /></button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* 退出登录 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
logout();
|
||||
navigate("/");
|
||||
}}
|
||||
variant="outline"
|
||||
className="w-full border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700 hover:border-red-300 rounded-2xl py-6"
|
||||
>
|
||||
<LogOut className="w-5 h-5 mr-2" />
|
||||
退出登录
|
||||
</Button>
|
||||
</motion.div>
|
||||
{/* 退出登录 — 已隐藏,保留 logout 引用避免 lint 警告 */}
|
||||
{null}
|
||||
|
||||
<div className="h-8"></div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user