fix: 修复混合认证方案 - Cookie + Authorization Header
- 修改 login/register API 使用 JSONResponse 正确设置 Cookie - 添加 path="/" 确保 Cookie 在所有路径可用 - 前端同时使用 localStorage token 进行 API 认证 - 修复登录后闪屏返回登录页的问题 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,13 +2,13 @@
|
||||
|
||||
# 管理员账户(首次启动时自动创建)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change_me_please
|
||||
ADMIN_PASSWORD=admin0905
|
||||
|
||||
# JWT 密钥(请使用随机字符串,可用 openssl rand -hex 32 生成)
|
||||
JWT_SECRET=your_random_secret_key_here
|
||||
|
||||
# AI 食物识别(可选,二选一)
|
||||
# 阿里云通义千问
|
||||
DASHSCOPE_API_KEY=your_dashscope_api_key_here
|
||||
DASHSCOPE_API_KEY=sk-e3b36e63947445c8bb51a87a7ef15850dashscope_api_key_here
|
||||
# DeepSeek
|
||||
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
||||
|
||||
91
README.md
91
README.md
@@ -345,17 +345,88 @@ vitals/
|
||||
|
||||
## 开发进度
|
||||
|
||||
### 已完成功能
|
||||
### 已完成功能 ✅
|
||||
|
||||
- [x] **P1-1** 运动页面 - 运动记录列表、统计图表、数据录入
|
||||
- [x] **P1-2** 饮食页面 - 饮食记录、营养统计、日历视图
|
||||
- [x] **P1-3** 睡眠/体重页面 - 睡眠趋势、体重曲线、BMI 计算
|
||||
- [x] **P1-4** Web 数据录入 - 所有数据类型的表单录入
|
||||
- [x] **P1-5** AI 智能识别 - 通义千问/DeepSeek 食物识别
|
||||
- [x] **P2-1** 数据导出 - JSON/CSV 格式导出
|
||||
- [x] **P2-2** 自动备份 - 数据库备份与恢复
|
||||
- [x] **P3-1** 定时提醒 - 系统通知、定时任务
|
||||
- [x] **公网部署** - JWT 认证、邀请码注册、管理员面板、Docker 部署
|
||||
#### 核心数据追踪
|
||||
- [x] **运动记录** - 类型、时长、距离、卡路里、心率追踪,支持 Garmin/Codoon/CSV 导入
|
||||
- [x] **饮食记录** - 三餐+加餐,营养数据(卡路里/蛋白质/碳水/脂肪),照片上传
|
||||
- [x] **睡眠记录** - 入睡/起床时间、时长、质量评分(1-5)、深睡时长
|
||||
- [x] **体重记录** - 体重(kg)、体脂率(%)、肌肉量,趋势分析
|
||||
- [x] **阅读记录** - 书名、作者、封面(自动获取)、时长、心情(5种表情)、读后感
|
||||
|
||||
#### 用户认证与管理
|
||||
- [x] **JWT 认证** - 服务端认证 + Cookie,支持 1 天/30 天过期
|
||||
- [x] **密码安全** - bcrypt 哈希加密
|
||||
- [x] **多用户支持** - 用户档案、数据隔离、用户切换
|
||||
- [x] **邀请码注册** - 控制用户注册,支持有效期设置
|
||||
- [x] **管理员面板** - 用户管理(启用/禁用/删除)、邀请码管理
|
||||
|
||||
#### Web 应用
|
||||
- [x] **完整页面** - 登录、注册、管理员、首页、运动、饮食、睡眠、体重、阅读、报告、设置
|
||||
- [x] **认证中间件** - 保护所有页面(登录/注册除外)
|
||||
- [x] **REST API** - 40+ 个接口,完整的 CRUD 操作
|
||||
- [x] **照片管理** - 饮食照片上传和静态文件服务
|
||||
|
||||
#### 数据管理
|
||||
- [x] **数据导出** - JSON 和 CSV 格式
|
||||
- [x] **数据导入** - JSON 文件导入(带验证)
|
||||
- [x] **自动备份** - 数据写入后自动备份,7 天保留策略
|
||||
- [x] **备份恢复** - 从备份文件恢复数据
|
||||
- [x] **数据清理** - 按日期范围或数据类型删除,支持预览
|
||||
|
||||
#### AI 集成
|
||||
- [x] **食物识别** - 阿里通义千问(Qwen VL)、DeepSeek Vision、Claude Vision
|
||||
- [x] **卡路里估算** - 基于食物描述的营养值计算
|
||||
|
||||
#### 数据导入器
|
||||
- [x] **Garmin 导入** - 从 Garmin 设备导入运动数据
|
||||
- [x] **Codoon 导入** - 支持咔豆运动数据导入
|
||||
- [x] **CSV 导入** - 通用 CSV 导入(运动/饮食/睡眠/体重)
|
||||
|
||||
#### 报告与分析
|
||||
- [x] **今日概览** - 所有健康指标汇总
|
||||
- [x] **本周汇总** - 7 天趋势分析
|
||||
- [x] **阅读统计** - 时长趋势、心情分布、书库展示
|
||||
- [x] **图表展示** - 运动趋势、营养统计、睡眠热力图
|
||||
|
||||
#### CLI 工具
|
||||
- [x] **数据记录** - `vitals log` 记录各类数据
|
||||
- [x] **数据查看** - `vitals show` 显示汇总
|
||||
- [x] **数据导出** - `vitals export` 导出 JSON/CSV
|
||||
- [x] **数据备份** - `vitals backup` 管理备份
|
||||
|
||||
#### 部署
|
||||
- [x] **Docker 支持** - Dockerfile + docker-compose.yml
|
||||
- [x] **环境变量配置** - 灵活的配置管理
|
||||
- [x] **健康检查** - Docker 容器健康检查
|
||||
|
||||
### 待完善功能 🚧
|
||||
|
||||
#### 高优先级
|
||||
- [ ] **提醒系统完善** - 定时提醒功能已有框架,但系统集成需要完善
|
||||
- [ ] **数据编辑功能** - 目前仅支持删除,需要添加编辑已有记录的功能
|
||||
- [ ] **错误处理优化** - 前端错误提示和异常处理需要更友好
|
||||
|
||||
#### 中优先级
|
||||
- [ ] **月度报告** - PDF 报告生成功能(已有框架,需完善)
|
||||
- [ ] **目标设定** - 运动目标、饮食目标、体重目标的设定和追踪
|
||||
- [ ] **数据统计增强** - 更丰富的图表和趋势分析
|
||||
- [ ] **移动端优化** - 响应式设计改进,更好的移动端体验
|
||||
- [ ] **离线支持** - PWA 支持,离线数据缓存
|
||||
|
||||
#### 低优先级
|
||||
- [ ] **国际化(i18n)** - 多语言支持
|
||||
- [ ] **云同步** - 跨设备数据同步
|
||||
- [ ] **社交功能** - 数据分享、好友挑战
|
||||
- [ ] **智能建议** - 基于数据的个性化健康建议
|
||||
- [ ] **Apple Health 集成** - 与 Apple Health 数据同步
|
||||
- [ ] **深色模式** - UI 深色主题支持
|
||||
|
||||
### 已知问题 🐛
|
||||
|
||||
- [ ] 大量数据时列表加载性能需优化
|
||||
- [ ] 部分页面在小屏幕上布局需要调整
|
||||
- [ ] 书籍封面搜索有时无法找到匹配结果
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -528,7 +528,7 @@ def get_current_user_id(authorization: Optional[str] = Header(None)) -> int:
|
||||
# ===== 认证 API =====
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def login(data: LoginInput, response: Response):
|
||||
async def login(data: LoginInput):
|
||||
"""用户登录"""
|
||||
user = db.get_user_by_name(data.username)
|
||||
if not user:
|
||||
@@ -543,6 +543,17 @@ async def login(data: LoginInput, response: Response):
|
||||
# 创建 token(根据 remember_me 设置不同的过期时间)
|
||||
token = create_token(user.id, user.name, user.is_admin, data.remember_me)
|
||||
|
||||
# 创建响应并设置 Cookie
|
||||
response = JSONResponse(content={
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"name": user.name,
|
||||
"is_admin": user.is_admin,
|
||||
"is_disabled": user.is_disabled,
|
||||
}
|
||||
})
|
||||
|
||||
# 设置 HTTPOnly Cookie
|
||||
max_age = get_token_expire_seconds(data.remember_me) if data.remember_me else None
|
||||
response.set_cookie(
|
||||
@@ -552,21 +563,14 @@ async def login(data: LoginInput, response: Response):
|
||||
secure=False, # HTTP 环境,生产环境应设为 True
|
||||
samesite="lax",
|
||||
max_age=max_age, # None 表示会话 Cookie
|
||||
path="/", # 确保所有路径都能使用此 Cookie
|
||||
)
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"name": user.name,
|
||||
"is_admin": user.is_admin,
|
||||
"is_disabled": user.is_disabled,
|
||||
}
|
||||
}
|
||||
return response
|
||||
|
||||
|
||||
@app.post("/api/auth/register")
|
||||
async def register(data: RegisterInput, response: Response):
|
||||
async def register(data: RegisterInput):
|
||||
"""用户注册(需要邀请码)"""
|
||||
# 验证邀请码
|
||||
invite = db.get_invite_by_code(data.invite_code)
|
||||
@@ -599,18 +603,9 @@ async def register(data: RegisterInput, response: Response):
|
||||
# 获取创建的用户
|
||||
user = db.get_user(user_id)
|
||||
|
||||
# 生成 token 并设置 Cookie
|
||||
# 生成 token 并创建响应
|
||||
token = create_token(user.id, user.name, user.is_admin)
|
||||
response.set_cookie(
|
||||
key="auth_token",
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=False,
|
||||
samesite="lax",
|
||||
max_age=get_token_expire_seconds(False), # 注册默认 1 天
|
||||
)
|
||||
|
||||
return {
|
||||
response = JSONResponse(content={
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
@@ -618,7 +613,20 @@ async def register(data: RegisterInput, response: Response):
|
||||
"is_admin": user.is_admin,
|
||||
"is_disabled": user.is_disabled,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# 设置 Cookie
|
||||
response.set_cookie(
|
||||
key="auth_token",
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=False,
|
||||
samesite="lax",
|
||||
max_age=get_token_expire_seconds(False), # 注册默认 1 天
|
||||
path="/",
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
@@ -2228,17 +2236,24 @@ def get_login_page_html() -> str:
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// 保存用户信息(非敏感)
|
||||
// 保存 token 和用户信息
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
|
||||
// 显示成功状态
|
||||
btn.textContent = '✓ 登录成功';
|
||||
btn.classList.add('success');
|
||||
|
||||
// 跳转
|
||||
// 跳转(管理员跳转到管理页面)
|
||||
setTimeout(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const redirect = params.get('redirect') || '/';
|
||||
let redirect = params.get('redirect');
|
||||
|
||||
// 如果没有指定重定向,管理员默认跳转到 /admin
|
||||
if (!redirect) {
|
||||
redirect = data.user.is_admin ? '/admin' : '/';
|
||||
}
|
||||
|
||||
window.location.href = redirect;
|
||||
}, 500);
|
||||
} else {
|
||||
@@ -2566,7 +2581,8 @@ def get_register_page_html() -> str:
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// 保存用户信息
|
||||
// 保存 token 和用户信息
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
|
||||
// 显示成功
|
||||
@@ -2959,18 +2975,28 @@ def get_admin_page_html() -> str:
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
|
||||
async function apiRequest(url, options = {}) {
|
||||
const token = getToken();
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
}
|
||||
headers
|
||||
});
|
||||
if (response.status === 401) {
|
||||
logout();
|
||||
@@ -7133,6 +7159,26 @@ def get_settings_page_html() -> str:
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 账户管理 -->
|
||||
<div class="section">
|
||||
<h2>账户</h2>
|
||||
<p style="color: #64748B; margin-bottom: 20px;">当前登录账户管理</p>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 20px; background: #F8FAFC; border-radius: 8px; margin-bottom: 20px;">
|
||||
<div>
|
||||
<div style="font-weight: 600; color: #1E293B; font-size: 1.1rem;" id="account-name">--</div>
|
||||
<div style="color: #64748B; font-size: 0.9rem; margin-top: 4px;" id="account-role">普通用户</div>
|
||||
</div>
|
||||
<div id="account-badge" style="display: none; padding: 4px 12px; background: #3B82F6; color: white; border-radius: 12px; font-size: 0.75rem; font-weight: 700;">
|
||||
管理员
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-danger" onclick="logoutAccount()" style="width: 100%;">
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 创建用户模态框 -->
|
||||
<div id="create-user-modal" class="modal">
|
||||
<div class="modal-content" style="max-width: 500px;">
|
||||
@@ -7575,10 +7621,50 @@ def get_settings_page_html() -> str:
|
||||
});
|
||||
});
|
||||
|
||||
// 加载账户信息
|
||||
async function loadAccountInfo() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', { credentials: 'same-origin' });
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
document.getElementById('account-name').textContent = user.name;
|
||||
document.getElementById('account-role').textContent = user.is_admin ? '管理员账户' : '普通用户';
|
||||
if (user.is_admin) {
|
||||
document.getElementById('account-badge').style.display = 'block';
|
||||
}
|
||||
// 保存到 localStorage
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载账户信息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
async function logoutAccount() {
|
||||
if (!confirm('确定要退出登录吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('登出失败:', error);
|
||||
}
|
||||
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
// 页面加载
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadUsers();
|
||||
loadProfile();
|
||||
loadAccountInfo();
|
||||
|
||||
// BMI 实时计算
|
||||
document.getElementById('profile-height').addEventListener('input', calculateBMI);
|
||||
|
||||
Reference in New Issue
Block a user