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:
2026-01-22 15:03:01 +08:00
parent 518e5c8284
commit 9fa6616110
3 changed files with 200 additions and 43 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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);