chore: 初始化项目结构并添加基础配置文件

添加后端API和前端项目的初始目录结构,包括应用层模块、工具模块和模型定义
配置前后端的.dockerignore文件以排除开发环境文件
添加前端样式文件,包括字体、Tailwind CSS和主题配置
初始化Python虚拟环境中的类型标记文件
This commit is contained in:
2026-04-01 16:55:24 +08:00
commit cfdeeb5e41
4149 changed files with 837587 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
from app.routers.auth import router as auth_router
from app.routers.user import router as user_router
from app.routers.report import router as report_router
from app.routers.analysis import router as analysis_router
from app.routers.chat import router as chat_router
from app.routers.health_records import router as health_records_router
from app.routers.calendar import router as calendar_router
from app.routers.doctor import router as doctor_router
from app.routers.share import router as share_router

View File

@@ -0,0 +1,147 @@
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from app.middleware.auth import get_current_user
from app.models.analysis import (
Analysis,
CategoryInterpretation,
IndicatorInterpretation,
ActionItem,
)
from app.models.report import Report
from app.models.user import User
from app.services.ai_service import analyze_report
from app.routers.calendar import link_report_to_checkup
from app.utils.helpers import parse_object_id
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/{report_id}/generate")
async def generate_analysis(
report_id: str, user: User = Depends(get_current_user)
):
"""生成 AI 解读"""
oid = parse_object_id(report_id)
report = await Report.get(oid)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="报告不存在")
# Check if analysis already exists
existing = await Analysis.find_one(Analysis.report_id == report.id)
if existing:
return _analysis_to_dict(existing)
# Call Claude API
try:
result = await analyze_report(report, user)
except Exception:
logger.exception("AI 解读失败 report_id=%s", report_id)
raise HTTPException(status_code=502, detail="AI 解读服务暂时不可用,请稍后重试")
# Save analysis
categories = []
for cat in result.get("categories", []):
indicators = [
IndicatorInterpretation(**ind) for ind in cat.get("indicators", [])
]
categories.append(
CategoryInterpretation(
category=cat.get("category", ""),
status=cat.get("status", "normal"),
indicators=indicators,
)
)
action_items = [ActionItem(**a) for a in result.get("action_items", [])]
analysis = Analysis(
report_id=report.id,
user_id=user.id,
overall_status=result.get("overall_status", "normal"),
risk_level=result.get("risk_level", "low"),
overall_summary=result.get("overall_summary", ""),
emotional_summary=result.get("emotional_summary", ""),
categories=categories,
action_items=action_items,
tips=result.get("tips", []),
)
await analysis.insert()
# Update report status
report.status = "analyzed"
report.overall_status = analysis.overall_status
report.updated_at = datetime.now(timezone.utc)
await report.save()
# Auto-link report to the nearest calendar checkup
if report.pregnancy_week:
try:
await link_report_to_checkup(user.id, str(report.id), report.pregnancy_week)
except Exception:
logger.warning("自动关联日历失败 report_id=%s", report_id)
return _analysis_to_dict(analysis)
@router.get("/{report_id}")
async def get_analysis(report_id: str, user: User = Depends(get_current_user)):
"""获取报告的 AI 解读结果"""
oid = parse_object_id(report_id)
report = await Report.get(oid)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="报告不存在")
analysis = await Analysis.find_one(Analysis.report_id == report.id)
if not analysis:
raise HTTPException(status_code=404, detail="暂无解读结果,请先生成")
return _analysis_to_dict(analysis)
def _analysis_to_dict(analysis: Analysis) -> dict:
return {
"id": str(analysis.id),
"report_id": str(analysis.report_id),
"overall_status": analysis.overall_status,
"risk_level": analysis.risk_level,
"overall_summary": analysis.overall_summary,
"emotional_summary": analysis.emotional_summary,
"categories": [
{
"category": cat.category,
"status": cat.status,
"indicators": [
{
"name": ind.name,
"nickname": ind.nickname,
"value": ind.value,
"unit": "",
"reference_range": "",
"status": ind.status,
"what_is_it": ind.what_is_it,
"baby_impact": ind.impact,
"suggestion": ind.suggestion,
"follow_up": ind.recheck_reminder or "",
}
for ind in cat.indicators
],
}
for cat in analysis.categories
],
"action_items": [
{
"priority": a.priority,
"title": a.content,
"description": a.category,
"timeframe": "",
}
for a in analysis.action_items
],
"tips": analysis.tips,
"created_at": analysis.created_at.isoformat(),
}

View File

@@ -0,0 +1,87 @@
import uuid
from fastapi import APIRouter, HTTPException
from pymongo.errors import DuplicateKeyError
from app.models.user import User
from app.schemas.auth import WechatLoginRequest, PhoneLoginRequest, TokenResponse
from app.middleware.auth import create_access_token
from app.services.wechat import code_to_session
router = APIRouter()
@router.post("/demo-login", response_model=TokenResponse)
async def demo_login():
"""Web 体验用的演示登录,每次创建独立的临时用户"""
demo_openid = f"demo_{uuid.uuid4().hex[:12]}"
user = User(
openid=demo_openid,
name="体验用户",
)
await user.insert()
token = create_access_token(str(user.id))
return TokenResponse(
access_token=token,
user_id=str(user.id),
is_new_user=True,
)
@router.post("/wechat-login", response_model=TokenResponse)
async def wechat_login(req: WechatLoginRequest):
"""微信小程序登录"""
try:
wechat_data = await code_to_session(req.code)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
openid = wechat_data["openid"]
user = await User.find_one(User.openid == openid)
is_new = user is None
if not user:
user = User(
openid=openid,
session_key=wechat_data.get("session_key"),
union_id=wechat_data.get("unionid"),
)
await user.insert()
else:
user.session_key = wechat_data.get("session_key")
await user.save()
token = create_access_token(str(user.id))
return TokenResponse(
access_token=token,
user_id=str(user.id),
is_new_user=is_new,
)
@router.post("/phone-login", response_model=TokenResponse)
async def phone_login(req: PhoneLoginRequest):
"""手机号登录(简化版,生产环境需接入短信验证)"""
# TODO: 接入真实短信验证服务
if req.sms_code != "123456":
raise HTTPException(status_code=400, detail="验证码错误")
user = await User.find_one(User.phone == req.phone)
is_new = user is None
if not user:
try:
user = User(openid=f"phone_{req.phone}", phone=req.phone)
await user.insert()
except DuplicateKeyError:
# 并发竞态:另一个请求已创建该用户,回退查找
user = await User.find_one(User.phone == req.phone)
is_new = False
token = create_access_token(str(user.id))
return TokenResponse(
access_token=token,
user_id=str(user.id),
is_new_user=is_new,
)

View File

@@ -0,0 +1,101 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from app.middleware.auth import get_current_user
from app.models.checkup import CheckupPlan, CheckupItem
from app.models.user import User
router = APIRouter()
# 标准产检计划
STANDARD_CHECKUPS = [
CheckupItem(title="首次产检", week=6, items=["B超确认宫内孕", "血常规", "尿常规", "肝功能", "肾功能"], preparations=["空腹", "带身份证和医保卡"], notes="确认怀孕和预产期", importance="high"),
CheckupItem(title="NT检查", week=12, items=["NT超声筛查", "血清学筛查", "血压", "体重"], preparations=["不需要空腹", "需憋尿"], notes="筛查唐氏综合征风险", importance="high"),
CheckupItem(title="唐氏筛查", week=16, items=["唐氏筛查抽血", "血压", "体重", "宫高腹围"], preparations=["空腹抽血"], notes="评估胎儿染色体异常风险", importance="high"),
CheckupItem(title="大排畸检查", week=22, items=["四维彩超", "血压", "体重", "宫高腹围"], preparations=["不需要空腹", "提前预约"], notes="全面筛查胎儿结构畸形", importance="high"),
CheckupItem(title="糖耐量检查", week=24, items=["OGTT糖耐量试验", "血压", "体重"], preparations=["空腹8-14小时", "检查前3天正常饮食"], notes="筛查妊娠期糖尿病", importance="high"),
CheckupItem(title="常规产检", week=28, items=["血常规", "尿常规", "血压", "体重", "胎心监护"], preparations=["空腹"], notes="进入孕晚期,增加产检频率", importance="medium"),
CheckupItem(title="常规产检", week=30, items=["B超评估胎儿发育", "血压", "体重", "胎心监护"], preparations=[], notes="评估胎儿生长发育", importance="medium"),
CheckupItem(title="常规产检", week=32, items=["血常规", "尿常规", "血压", "体重", "胎心监护", "B超"], preparations=["空腹"], notes="观察胎位", importance="medium"),
CheckupItem(title="GBS筛查", week=36, items=["B族链球菌筛查", "血压", "体重", "胎心监护", "内检"], preparations=[], notes="预防新生儿感染", importance="high"),
CheckupItem(title="每周产检", week=37, items=["胎心监护", "血压", "体重", "宫高腹围"], preparations=[], notes="每周一次,准备待产", importance="medium"),
CheckupItem(title="每周产检", week=38, items=["胎心监护", "B超评估", "血压", "体重"], preparations=[], notes="评估分娩时机", importance="medium"),
CheckupItem(title="每周产检", week=39, items=["胎心监护", "血压", "体重", "宫颈评估"], preparations=[], notes="关注产兆", importance="high"),
CheckupItem(title="预产期产检", week=40, items=["胎心监护", "B超", "血压", "体重", "催产评估"], preparations=[], notes="超过预产期需密切监护", importance="high"),
]
@router.get("/plan")
async def get_checkup_plan(user: User = Depends(get_current_user)):
"""获取产检计划"""
plan = await CheckupPlan.find_one(CheckupPlan.user_id == user.id)
if not plan:
# Generate default plan
plan = CheckupPlan(user_id=user.id, checkups=STANDARD_CHECKUPS)
await plan.insert()
# Mark completed checkups based on current week
current_week = user.pregnancy_week or 0
checkups = []
for c in plan.checkups:
status = c.status
if c.week < current_week and status == "upcoming":
status = "missed"
checkups.append({
**c.model_dump(),
"status": status,
})
# Find next checkup
next_checkup = None
for c in checkups:
if c["status"] == "upcoming":
next_checkup = c
break
return {
"checkups": checkups,
"next_checkup": next_checkup,
"current_week": current_week,
}
@router.put("/plan/{week}/complete")
async def complete_checkup(week: int, user: User = Depends(get_current_user)):
"""标记产检已完成"""
plan = await CheckupPlan.find_one(CheckupPlan.user_id == user.id)
if not plan:
raise HTTPException(status_code=404, detail="产检计划不存在")
for checkup in plan.checkups:
if checkup.week == week:
checkup.status = "completed"
break
plan.updated_at = datetime.now(timezone.utc)
await plan.save()
return {"message": "已标记完成"}
async def link_report_to_checkup(user_id, report_id: str, pregnancy_week: int):
"""将报告自动关联到最近的产检(±2周窗口),并标记该产检为已完成"""
plan = await CheckupPlan.find_one(CheckupPlan.user_id == user_id)
if not plan:
return
best = None
best_diff = 999
for c in plan.checkups:
diff = abs(c.week - pregnancy_week)
if diff <= 2 and diff < best_diff:
best = c
best_diff = diff
if best:
if report_id not in best.report_ids:
best.report_ids.append(report_id)
best.status = "completed"
plan.updated_at = datetime.now(timezone.utc)
await plan.save()

View File

@@ -0,0 +1,98 @@
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from app.middleware.auth import get_current_user
from app.models.chat import ChatSession, ChatMessage
from app.models.report import Report
from app.models.user import User
from app.schemas.chat import ChatMessageRequest, ChatSessionResponse, ChatMessageResponse
from app.services.ai_service import chat_with_ai
from app.utils.helpers import parse_object_id
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/{report_id}/message")
async def send_message(
report_id: str,
req: ChatMessageRequest,
user: User = Depends(get_current_user),
):
"""发送消息并获取 AI 回复"""
oid = parse_object_id(report_id)
report = await Report.get(oid)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="报告不存在")
# Get or create chat session
session = await ChatSession.find_one(
ChatSession.user_id == user.id,
ChatSession.report_id == report.id,
)
if not session:
session = ChatSession(user_id=user.id, report_id=report.id)
await session.insert()
# Add user message
user_msg = ChatMessage(role="user", content=req.content)
session.messages.append(user_msg)
# Get AI response
history = [{"role": m.role, "content": m.content} for m in session.messages[:-1]]
try:
ai_response = await chat_with_ai(report, user, history, req.content)
except Exception:
logger.exception("AI 对话失败 report_id=%s", report_id)
raise HTTPException(status_code=502, detail="AI 对话服务暂时不可用,请稍后重试")
# Add AI message
ai_msg = ChatMessage(role="assistant", content=ai_response)
session.messages.append(ai_msg)
session.updated_at = datetime.now(timezone.utc)
await session.save()
return {
"user_message": {"role": "user", "content": req.content, "timestamp": user_msg.timestamp.isoformat()},
"ai_message": {"role": "assistant", "content": ai_response, "timestamp": ai_msg.timestamp.isoformat()},
}
@router.get("/{report_id}/history")
async def get_chat_history(
report_id: str, user: User = Depends(get_current_user)
):
"""获取聊天历史"""
oid = parse_object_id(report_id)
report = await Report.get(oid)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="报告不存在")
session = await ChatSession.find_one(
ChatSession.user_id == user.id,
ChatSession.report_id == report.id,
)
if not session:
return {"messages": [], "quick_questions": _get_quick_questions()}
return {
"messages": [
{"role": m.role, "content": m.content, "timestamp": m.timestamp.isoformat()}
for m in session.messages
],
"quick_questions": _get_quick_questions(),
}
def _get_quick_questions() -> list[str]:
return [
"这个指标偏高会影响宝宝吗?",
"我需要注意什么饮食?",
"什么时候需要复查?",
"这个结果严重吗?",
"有什么改善建议?",
]

View File

@@ -0,0 +1,96 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from app.middleware.auth import get_current_user
from app.models.report import Report
from app.models.user import User
from app.services.ai_service import generate_doctor_questions
from app.utils.helpers import parse_object_id
logger = logging.getLogger(__name__)
router = APIRouter()
# 预设医生列表
DOCTORS = [
{
"id": "1",
"name": "王丽华",
"title": "主任医师",
"hospital": "北京协和医院",
"specialty": "高危妊娠、产前诊断",
"experience": "25年",
"rating": 4.9,
"review_count": 1280,
"consult_count": 5600,
"response_time": "30分钟内",
"price": {"text": 50, "video": 200, "urgent": 100},
"tags": ["高危妊娠", "产前诊断", "遗传咨询"],
},
{
"id": "2",
"name": "张美琳",
"title": "副主任医师",
"hospital": "上海第一妇婴保健院",
"specialty": "孕期营养、妊娠期糖尿病",
"experience": "18年",
"rating": 4.8,
"review_count": 960,
"consult_count": 3800,
"response_time": "1小时内",
"price": {"text": 40, "video": 180, "urgent": 80},
"tags": ["孕期营养", "妊娠糖尿病", "体重管理"],
},
{
"id": "3",
"name": "刘晓芳",
"title": "主任医师",
"hospital": "广州市妇女儿童医疗中心",
"specialty": "超声诊断、胎儿医学",
"experience": "20年",
"rating": 4.9,
"review_count": 1100,
"consult_count": 4200,
"response_time": "2小时内",
"price": {"text": 45, "video": 190, "urgent": 90},
"tags": ["超声诊断", "胎儿医学", "产前筛查"],
},
]
@router.get("/questions/{report_id}")
async def get_doctor_questions(
report_id: str, user: User = Depends(get_current_user)
):
"""生成问诊清单"""
oid = parse_object_id(report_id)
report = await Report.get(oid)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="报告不存在")
try:
result = await generate_doctor_questions(report, user)
except Exception:
logger.exception("问诊清单生成失败 report_id=%s", report_id)
raise HTTPException(status_code=502, detail="AI 服务暂时不可用,请稍后重试")
return {
"report_id": report_id,
"questions": result.get("questions", []),
}
@router.get("/experts")
async def list_experts():
"""获取专家医生列表"""
return {"doctors": DOCTORS}
@router.get("/experts/{doctor_id}")
async def get_expert_detail(doctor_id: str):
"""获取医生详情"""
for doc in DOCTORS:
if doc["id"] == doctor_id:
return doc
raise HTTPException(status_code=404, detail="医生不存在")

View File

@@ -0,0 +1,258 @@
import re
from typing import Optional, Tuple
from beanie import PydanticObjectId
from fastapi import APIRouter, Depends
from app.middleware.auth import get_current_user
from app.models.report import Report
from app.models.user import User
router = APIRouter()
def _parse_numeric_value(value) -> Optional[float]:
value_str = str(value).strip() if value is not None else ""
if not value_str:
return None
match = re.search(r'(\d+\.?\d*)', value_str)
if not match:
return None
try:
return float(match.group(1))
except (ValueError, AttributeError):
return None
def _parse_reference_range(reference_range: str) -> Tuple[Optional[float], Optional[float]]:
if not reference_range:
return None, None
match = re.search(r'(\d+\.?\d*)\s*[-~]\s*(\d+\.?\d*)', reference_range)
if not match:
return None, None
return float(match.group(1)), float(match.group(2))
@router.get("/overview")
async def get_health_overview(user: User = Depends(get_current_user)):
"""健康档案概览"""
reports = await Report.find(Report.user_id == user.id).sort("-created_at").to_list()
total = len(reports)
normal = sum(1 for r in reports if r.overall_status == "normal")
attention = sum(1 for r in reports if r.overall_status == "attention")
warning = sum(1 for r in reports if r.overall_status == "warning")
recent = [
{
"id": str(r.id),
"report_type": r.report_type,
"overall_status": r.overall_status,
"created_at": r.created_at.isoformat(),
}
for r in reports[:5]
]
return {
"stats": {
"total": total,
"normal": normal,
"attention": attention,
"warning": warning,
},
"recent_reports": recent,
}
@router.get("/growth-curve")
async def get_growth_curve(user: User = Depends(get_current_user)):
"""获取胎儿生长曲线数据(从B超报告提取)"""
reports = await Report.find(
Report.user_id == user.id,
Report.report_type == "B超",
).sort("pregnancy_week").to_list()
data_points = []
for r in reports:
if not r.pregnancy_week:
continue
point = {"week": r.pregnancy_week}
for item in r.data_items:
name_lower = item.name.lower()
numeric_value = _parse_numeric_value(item.value)
if numeric_value is None:
continue
# 匹配指标名称
if "双顶径" in item.name or "bpd" in name_lower:
point["bpd"] = numeric_value
elif "腹围" in item.name or "ac" in name_lower:
point["ac"] = numeric_value
elif "股骨长" in item.name or "fl" in name_lower:
point["fl"] = numeric_value
# 只有包含至少一个生长指标的数据点才添加
if any(k in point for k in ["bpd", "ac", "fl"]):
data_points.append(point)
return {"data_points": data_points}
@router.get("/timeline")
async def get_timeline(user: User = Depends(get_current_user)):
"""报告时间轴"""
reports = await Report.find(Report.user_id == user.id).sort("-created_at").to_list()
timeline = []
for r in reports:
week = r.pregnancy_week or 0
if week <= 12:
period = "early"
elif week <= 28:
period = "mid"
else:
period = "late"
timeline.append({
"id": str(r.id),
"report_type": r.report_type,
"date": r.created_at.isoformat(),
"status": r.overall_status,
"summary": f"{len(r.data_items)}项指标",
"period": period,
"week": week,
})
return {"reports": timeline}
@router.get("/indicator-trends")
async def get_indicator_trends(user: User = Depends(get_current_user)):
"""获取跨报告指标趋势数据(同名数值指标出现2+次的)"""
reports = await Report.find(
Report.user_id == user.id,
).sort("pregnancy_week", "created_at").to_list()
# 收集所有指标:indicator_name -> list of {week, value, unit, ref_range, status, date, report_type}
indicator_map: dict[str, list[dict]] = {}
for r in reports:
week = r.pregnancy_week or 0
date_str = r.created_at.isoformat() if r.created_at else ""
for item in r.data_items:
value_str = str(item.value).strip() if item.value else ""
if not value_str:
continue
match = re.search(r'(\d+\.?\d*)', value_str)
if not match:
continue
try:
numeric_value = float(match.group(1))
except (ValueError, AttributeError):
continue
name = item.name.strip()
if name not in indicator_map:
indicator_map[name] = []
indicator_map[name].append({
"week": week,
"value": numeric_value,
"unit": item.unit or "",
"reference_range": item.reference_range or "",
"status": item.status or "normal",
"date": date_str,
"report_type": r.report_type,
})
# 保留所有有数据的指标(含仅1次的,用于综合分析)
indicators = []
for name, points in indicator_map.items():
# 解析参考范围(取第一个非空的)
ref_lower = None
ref_upper = None
for p in points:
if p["reference_range"]:
# 尝试解析 "3.5-5.0" 或 "3.5~5.0" 格式
ref_match = re.search(r'(\d+\.?\d*)\s*[-~]\s*(\d+\.?\d*)', p["reference_range"])
if ref_match:
ref_lower = float(ref_match.group(1))
ref_upper = float(ref_match.group(2))
break
indicators.append({
"name": name,
"unit": points[0]["unit"],
"ref_lower": ref_lower,
"ref_upper": ref_upper,
"points": [{"week": p["week"], "value": p["value"], "date": p["date"],
"status": p["status"], "report_type": p["report_type"]} for p in points],
})
return {"indicators": indicators}
@router.get("/report-type-trends")
async def get_report_type_trends(user: User = Depends(get_current_user)):
"""按报告类型分组的指标趋势(同类型报告中出现2+次的数值指标)"""
reports = await Report.find(
Report.user_id == user.id,
).sort("pregnancy_week", "created_at").to_list()
# report_type -> indicator_name -> list of data points
type_map: dict[str, dict[str, list[dict]]] = {}
for r in reports:
rt = r.report_type or "其他"
week = r.pregnancy_week or 0
date_str = r.created_at.isoformat() if r.created_at else ""
if rt not in type_map:
type_map[rt] = {}
for item in r.data_items:
numeric_value = _parse_numeric_value(item.value)
if numeric_value is None:
continue
name = item.name.strip()
if name not in type_map[rt]:
type_map[rt][name] = []
type_map[rt][name].append({
"week": week,
"value": numeric_value,
"unit": item.unit or "",
"reference_range": item.reference_range or "",
"status": item.status or "normal",
"date": date_str,
})
# 组装结果:保留所有有数据的指标(含仅1个数据点的)
result = []
for rt, ind_map in type_map.items():
indicators = []
for name, points in ind_map.items():
ref_lower, ref_upper = None, None
for p in points:
ref_lower, ref_upper = _parse_reference_range(p["reference_range"])
if ref_lower is not None:
break
indicators.append({
"name": name,
"unit": points[0]["unit"],
"ref_lower": ref_lower,
"ref_upper": ref_upper,
"points": [{"week": p["week"], "value": p["value"], "date": p["date"],
"status": p["status"]} for p in points],
})
if indicators:
# 按数据点数量降序,让最有数据的指标排前面
indicators.sort(key=lambda x: len(x["points"]), reverse=True)
result.append({
"report_type": rt,
"report_count": sum(1 for r in reports if (r.report_type or "其他") == rt),
"indicators": indicators,
})
# 按报告份数降序
result.sort(key=lambda x: x["report_count"], reverse=True)
return {"report_types": result}

View File

@@ -0,0 +1,290 @@
import logging
from datetime import datetime, timezone, date
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from app.middleware.auth import get_current_user
from app.models.report import Report, DataItem
from app.models.user import User
from app.schemas.report import (
ReportVerifyRequest,
ReportResponse,
ReportListItem,
DataItemSchema,
)
from app.services.storage import save_upload_file
from app.services.ocr import ocr_recognize
from app.utils.helpers import parse_object_id, mask_name
logger = logging.getLogger(__name__)
router = APIRouter()
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
@router.post("/upload")
async def upload_report_image(
file: UploadFile = File(...),
user: User = Depends(get_current_user),
):
"""上传报告图片并进行 OCR 识别"""
# Validate file type
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=400,
detail=f"不支持的文件类型: {file.content_type},请上传 JPG/PNG/WebP 图片",
)
# Validate file size (read content and check)
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(status_code=400, detail="文件大小不能超过 10MB")
# Reset file position for storage
await file.seek(0)
url = await save_upload_file(file, sub_dir="reports")
# OCR recognition
try:
ocr_result = await ocr_recognize(url)
except FileNotFoundError:
raise HTTPException(status_code=500, detail="文件保存异常,请重试")
except RuntimeError as e:
# OCR 服务已捕获并转换为友好消息
logger.warning("OCR 识别失败: %s", e)
raise HTTPException(status_code=503, detail=str(e))
except Exception as e:
logger.exception("OCR 识别失败")
raise HTTPException(
status_code=500,
detail="AI识别服务暂时不可用,请稍后重试",
)
# 检查是否为孕期相关报告
if not ocr_result.get("is_pregnancy_report", True):
reject_reason = ocr_result.get("reject_reason", "")
raise HTTPException(
status_code=422,
detail=f"请上传孕期检查报告。{reject_reason}",
)
# 根据报告日期 + 末次经期推算检查时的孕周
report_date_str = ocr_result.get("report_date")
pregnancy_week_at_report = user.pregnancy_week # 默认用用户当前孕周
if report_date_str and user.last_period_date:
try:
report_dt = date.fromisoformat(report_date_str)
lmp = date.fromisoformat(user.last_period_date) if isinstance(user.last_period_date, str) else user.last_period_date
delta_days = (report_dt - lmp).days
if 0 < delta_days < 300:
pregnancy_week_at_report = delta_days // 7
except (ValueError, TypeError):
pass
# Create report with OCR data
data_items = [DataItem(**item) for item in ocr_result.get("data_items", [])]
report = Report(
user_id=user.id,
report_type=ocr_result.get("report_type", ""),
pregnancy_week=pregnancy_week_at_report,
report_date=report_date_str,
image_urls=[url],
data_items=data_items,
patient_name=ocr_result.get("patient_name"),
status="pending",
)
await report.insert()
return {
"report_id": str(report.id),
"image_url": url,
"ocr_result": {
"report_type": report.report_type,
"data_items": [item.model_dump() for item in data_items],
"patient_name": mask_name(report.patient_name),
},
}
@router.post("/{report_id}/add-image")
async def add_image_to_report(
report_id: str,
file: UploadFile = File(...),
user: User = Depends(get_current_user),
):
"""向已有报告追加图片并进行 OCR 识别,合并数据"""
oid = parse_object_id(report_id)
report = await Report.get(oid)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="报告不存在")
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=400,
detail=f"不支持的文件类型: {file.content_type},请上传 JPG/PNG/WebP 图片",
)
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(status_code=400, detail="文件大小不能超过 10MB")
await file.seek(0)
url = await save_upload_file(file, sub_dir="reports")
# OCR recognition
try:
ocr_result = await ocr_recognize(url)
except FileNotFoundError:
raise HTTPException(status_code=500, detail="文件保存异常,请重试")
except RuntimeError as e:
logger.warning("OCR 识别失败: %s", e)
raise HTTPException(status_code=503, detail=str(e))
except Exception:
logger.exception("OCR 识别失败")
raise HTTPException(status_code=500, detail="AI识别服务暂时不可用,请稍后重试")
# 检查是否为孕期相关报告
if not ocr_result.get("is_pregnancy_report", True):
reject_reason = ocr_result.get("reject_reason", "")
raise HTTPException(
status_code=422,
detail=f"请上传孕期检查报告。{reject_reason}",
)
# Merge new data into existing report
new_items = [DataItem(**item) for item in ocr_result.get("data_items", [])]
report.image_urls.append(url)
report.data_items.extend(new_items)
# Update report_type if current report has multiple types
new_type = ocr_result.get("report_type", "")
if new_type and new_type != report.report_type:
report.report_type = f"{report.report_type}+{new_type}" if report.report_type else new_type
report.updated_at = datetime.now(timezone.utc)
await report.save()
return {
"report_id": str(report.id),
"image_url": url,
"image_count": len(report.image_urls),
"ocr_result": {
"report_type": new_type,
"data_items": [item.model_dump() for item in new_items],
"patient_name": mask_name(ocr_result.get("patient_name")),
},
"total_items": len(report.data_items),
}
@router.put("/{report_id}/verify", response_model=ReportResponse)
async def verify_report(
report_id: str,
req: ReportVerifyRequest,
user: User = Depends(get_current_user),
):
"""数据校对确认"""
oid = parse_object_id(report_id)
report = await Report.get(oid)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="报告不存在")
report.data_items = [DataItem(**item.model_dump()) for item in req.data_items]
report.symptoms = req.symptoms
report.notes = req.notes
if req.report_type:
report.report_type = req.report_type
report.status = "verified"
# Calculate overall status
statuses = [item.status for item in report.data_items]
if any(s in ("high", "low") for s in statuses):
abnormal_count = sum(1 for s in statuses if s != "normal")
report.overall_status = "warning" if abnormal_count >= 3 else "attention"
else:
report.overall_status = "normal"
report.updated_at = datetime.now(timezone.utc)
await report.save()
return _report_to_response(report)
@router.get("/list", response_model=list[ReportListItem])
async def list_reports(
limit: int = 50,
user: User = Depends(get_current_user),
):
"""获取报告列表"""
reports = await Report.find(
Report.user_id == user.id
).sort("-created_at").limit(limit).to_list()
return [
ReportListItem(
id=str(r.id),
report_type=r.report_type,
pregnancy_week=r.pregnancy_week,
overall_status=r.overall_status,
summary=_generate_summary(r),
item_count=len(r.data_items),
created_at=r.created_at,
)
for r in reports
]
@router.get("/{report_id}", response_model=ReportResponse)
async def get_report(report_id: str, user: User = Depends(get_current_user)):
"""获取报告详情"""
oid = parse_object_id(report_id)
report = await Report.get(oid)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="报告不存在")
return _report_to_response(report)
def _generate_summary(report: Report) -> str:
"""从报告指标中生成有意义的摘要文本"""
if not report.data_items:
return "暂无指标数据"
# 优先展示异常指标
abnormal = [
item for item in report.data_items if item.status in ("high", "low")
]
# 再补充正常的关键指标
normal = [item for item in report.data_items if item.status == "normal"]
parts = []
# 先添加异常指标(最多2个)
for item in abnormal[:2]:
flag = "↑偏高" if item.status == "high" else "↓偏低"
val = f"{item.name}{item.value}{item.unit}({flag})" if item.unit else f"{item.name}{item.value}({flag})"
parts.append(val)
# 再补充正常指标,总共最多3个
for item in normal[:3 - len(parts)]:
val = f"{item.name}{item.value}{item.unit}" if item.unit else f"{item.name}{item.value}"
parts.append(val)
if not parts:
return f"共{len(report.data_items)}项指标"
return ",".join(parts)
def _report_to_response(report: Report) -> ReportResponse:
return ReportResponse(
id=str(report.id),
report_type=report.report_type,
pregnancy_week=report.pregnancy_week,
image_urls=report.image_urls,
data_items=[DataItemSchema(**item.model_dump()) for item in report.data_items],
symptoms=report.symptoms,
notes=report.notes,
status=report.status,
overall_status=report.overall_status,
created_at=report.created_at,
)

View File

@@ -0,0 +1,70 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.middleware.auth import get_current_user
from app.models.analysis import Analysis
from app.models.report import Report
from app.models.user import User
from app.utils.helpers import parse_object_id, mask_name
router = APIRouter()
class ShareOptions(BaseModel):
privacy_mode: bool = True # 隐私模式,脱敏个人信息
include_details: bool = False # 是否包含详细数据
@router.post("/{report_id}/generate")
async def generate_share_data(
report_id: str,
options: ShareOptions = ShareOptions(privacy_mode=True, include_details=True),
user: User = Depends(get_current_user),
):
"""生成分享数据"""
oid = parse_object_id(report_id)
report = await Report.get(oid)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="报告不存在")
analysis = await Analysis.find_one(Analysis.report_id == report.id)
if not analysis:
raise HTTPException(status_code=404, detail="暂无解读结果")
# Build share data
share_data = {
"report_type": report.report_type,
"pregnancy_week": report.pregnancy_week,
"overall_status": analysis.overall_status,
"risk_level": analysis.risk_level,
"overall_summary": analysis.overall_summary,
"emotional_summary": analysis.emotional_summary,
"action_items": [a.model_dump() for a in analysis.action_items],
"tips": analysis.tips,
"categories": [
{
"category": cat.category,
"status": cat.status,
"indicators": [
{
"name": ind.name,
"nickname": ind.nickname,
"value": ind.value,
"status": ind.status,
"what_is_it": ind.what_is_it,
"impact": ind.impact,
"suggestion": ind.suggestion,
}
for ind in cat.indicators
],
}
for cat in analysis.categories
],
}
if not options.privacy_mode:
share_data["user_name"] = user.name
else:
share_data["user_name"] = mask_name(user.name)
return {"share_data": share_data}

View File

@@ -0,0 +1,84 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from app.middleware.auth import get_current_user
from app.models.user import User
from app.schemas.user import (
OnboardingRequest,
UserProfileUpdate,
UserProfileResponse,
)
router = APIRouter()
def _user_to_response(user: User) -> UserProfileResponse:
return UserProfileResponse(
id=str(user.id),
name=user.name,
phone=user.phone,
age=user.age,
height=user.height,
pre_pregnancy_weight=user.pre_pregnancy_weight,
current_weight=user.current_weight,
blood_type=user.blood_type,
allergies=user.allergies,
medical_history=user.medical_history,
last_period_date=user.last_period_date,
due_date=user.due_date,
pregnancy_week=user.pregnancy_week,
pregnancy_day=user.pregnancy_day,
multiple_pregnancy=user.multiple_pregnancy,
pregnancy_count=user.pregnancy_count,
emergency_contact=user.emergency_contact.model_dump() if user.emergency_contact else None,
is_onboarded=user.is_onboarded,
avatar_url=user.avatar_url,
created_at=user.created_at,
)
@router.get("/profile", response_model=UserProfileResponse)
async def get_profile(user: User = Depends(get_current_user)):
"""获取用户信息"""
return _user_to_response(user)
@router.post("/onboarding", response_model=UserProfileResponse)
async def complete_onboarding(
req: OnboardingRequest, user: User = Depends(get_current_user)
):
"""完成用户画像设置"""
user.name = req.name or user.name
user.age = req.age
user.height = req.height
user.pre_pregnancy_weight = req.pre_pregnancy_weight
user.current_weight = req.current_weight
user.blood_type = req.blood_type
user.allergies = req.allergies
user.medical_history = req.medical_history
user.last_period_date = req.last_period_date
user.due_date = req.due_date
user.pregnancy_week = req.pregnancy_week
user.pregnancy_day = req.pregnancy_day
user.multiple_pregnancy = req.multiple_pregnancy
user.pregnancy_count = req.pregnancy_count
if req.emergency_contact:
user.emergency_contact = req.emergency_contact
user.is_onboarded = True
user.updated_at = datetime.now(timezone.utc)
await user.save()
return _user_to_response(user)
@router.put("/profile", response_model=UserProfileResponse)
async def update_profile(
req: UserProfileUpdate, user: User = Depends(get_current_user)
):
"""更新用户信息"""
update_data = req.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(user, field, value)
user.updated_at = datetime.now(timezone.utc)
await user.save()
return _user_to_response(user)