守望康:我用 FastAPI + Vue 3 做了一个社区养老互助平台,完整技术复盘
我是风吹夏回。这是我的第二个全栈项目"守望康"——一个面向社区老人的互助+健康管理平台。本文复盘从架构设计到核心实现,附带 6 个真实踩坑记录。
为什么做这个项目?
国家统计局数据显示,中国 60 岁以上人口已突破2.9 亿,占总人口的 21.1%,预计 2035 年将突破 4 亿。在"9073"养老格局下,90% 的老人选择居家养老,但社区养老服务数字化程度严重不足——很多独居老人遇到困难只能打电话,甚至不知道找谁帮忙。
与此同时,低龄健康老人和社区志愿者的服务潜力远未被激活:他们有时间和意愿提供帮助,却缺少一个高效的信息匹配平台。
基于这个背景,我开发了守望康,Slogan:邻里守望,健康常在。
技术栈:Vue 3 + TypeScript + Pinia(前端) / FastAPI + SQLAlchemy 2.0 + Pydantic v2(后端) / MySQL + Redis + RabbitMQ + Elasticsearch / DeepSeek AI / 阿里云 NLS 语音识别 / Docker Compose 部署。
一、用户认证:登录注册 + JWT 双 Token + 三端分流
1.1 两种登录方式
登录页支持密码登录和短信验证码登录切换,内置三个测试账号(老人/志愿者/管理员各一个),一键填充方便演示:
consttestAccounts=[{label:'老人端',phone:'13900000001',password:'elder123'},{label:'志愿者端',phone:'13700000001',password:'vol123'},{label:'管理员端',phone:'13800000000',password:'admin123'}]1.2 注册流程
注册时选择角色(老人/志愿者)、填写手机号、获取短信验证码、设置密码:
// 注册表单校验constrole=ref<'elder'|'volunteer'>('elder')// 手机号正则校验 → 6 位短信码 → 密码 ≥ 6 位 → 确认密码一致 → 真实姓名1.3 后端 JWT 鉴权 + RBAC
后端采用JWT 双 Token机制:access_token(24h,日常鉴权)+refresh_token(30d,无感刷新)。Token payload 包含type字段区分类型,鉴权时强制校验type == "access",防止 refresh token 被用于接口请求。
# core/security.pydefcreate_access_token(subject,role,expires_minutes=1440):payload={"sub":str(subject),"role":role,"exp":datetime.now(timezone.utc)+timedelta(minutes=expires_minutes),"type":"access"}returnjwt.encode(payload,settings.secret_key,algorithm="HS256")权限控制使用 FastAPIDepends依赖链:HTTPBearer→get_current_user(解码 JWT)→get_current_user_obj(查 DB)→require_roles(角色校验),每个环节只做一件事:
defrequire_roles(*allowed_roles:str):asyncdef_checker(user:User=Depends(get_current_user_obj))->User:ifuser.rolenotinallowed_roles:raiseHTTPException(status_code=403)returnuserreturn_checker# 使用示例:只有老人能创建求助@router.post("/orders")asyncdefcreate_order(body:OrderCreate,user:User=Depends(require_roles("elder"))):...密码用bcrypt哈希,直接调原生库而非passlib(避免兼容性问题),密码截断到 72 字节(bcrypt 上限)。登录失败累计 5 次自动锁定账号。
1.4 前端路由守卫 + 三端分流
登录成功后,路由守卫根据role字段自动跳转到对应首页:
// router/index.tsconstROLE_HOME={elder:'/elder/home',volunteer:'/volunteer/home',admin:'/admin/dashboard',}router.beforeEach(async(to,_from,next)=>{consttoken=localStorage.getItem('sk-access-token')// 有 token → fetchUser 验证 → 有效就跳角色首页// 无 token → 公开页面放行,其他重定向到 /login})Axios 请求拦截器自动注入 Bearer Token,401 时清除 token 并跳转登录页。踩坑点:401 拦截器用动态import('@/router')而非静态import,避免request.ts → stores/auth.ts → router/index.ts的循环依赖。
二、老人端:适老化不是放大字体那么简单
老人端是整个项目的核心。做养老产品,UI 是第一个要过的坎——60 岁以上的用户,老花眼、手指不灵活、对复杂操作有畏难心理。我从四个维度做了系统性的适老化设计。
2.1 三套 CSS 主题变量体系
不是简单改几行样式,而是为三个角色各建了一套完整的 CSS 变量体系,通过html的 className 切换:
// stores/theme.tsconstrole=ref<'elder'|'volunteer'|'admin'>('elder')constfontSize=ref<'base'|'xl'|'xxl'>('base')// 老人端专属functionapply(){document.documentElement.className=`theme-${role.value}font-${fontSize.value}`}三端对比:
| 维度 | 老人端 | 志愿者端 | 管理后台 |
|---|---|---|---|
| 正文字号 | 20px(最大 28px) | 16px | 14px |
| 最小字号 | 16px | 12px | 12px |
| 按钮最小高度 | 56px | 40px | 32px |
| 背景色 | #FFF8EB 暖白护眼 | #F8FAFC | #FFFFFF |
| 容器最大宽 | 720px | 1200px | 1200px |
配色刻意避开纯白背景(刺眼),老人端用#FFF8EB暖白,对比度 ≥ 4.5:1 符合 WCAG 标准。对 Element Plus 做了全局覆写:
.theme-elder .el-button{min-height:56px;font-size:var(--text-body);padding:12px 24px;}.theme-elder .el-input__wrapper{min-height:56px;font-size:var(--text-body);}2.2 首页设计:五层信息分层
首页从上到下分五层,核心操作一眼可见:
<!-- 1. Hero 区:日期 + 个性化问候 --> <TodayHero :name="userName" /> <!-- 2. SOS 按钮:120px 红色脉冲大按钮,3 圈金色光环动画 --> <SOSButton @trigger="onSOS" /> <!-- 3. 6 格服务入口:每种服务独立配色,不靠文字也能区分 --> <ServiceCard icon={HandHeart} title="发起求助" color="primary" /> <ServiceCard icon={Heart} title="健康档案" color="emerald" /> <ServiceCard icon={ShoppingCart} title="健康商城" color="warm" badge="新人有礼" /> <ServiceCard icon={User} title="我的" color="lavender" /> <ServiceCard icon={ShieldAlert} title="反诈查验" color="rose" /> <ServiceCard icon={Sparkles} title="AI健康助手" color="indigo" /> <!-- 4. 今日用药提醒 --> <!-- 5. 最近服务 -->6 个 ServiceCard 各有独立配色(深海蓝/翡翠绿/暖金黄/紫罗兰/玫瑰红/靛蓝),老人即使不认字也能靠颜色找到功能。
2.3 SOS 紧急呼叫
点击 SOS 进入三个阶段:
确认 → 全屏红色倒计时(5s,Web Audio 警笛声)→ 完成/失败前端:SOSButton 组件 120px 高度,3 圈金色脉冲光环每 2.4 秒循环,72px 金色电话图标每 3 秒抖动:
<button class="sos-button"> <span class="sos-pulse-ring" v-for="i in 3" :key="i" :style="{ animationDelay: `${(i - 1) * 0.8}s` }" /> <span class="sos-icon"><Phone :size="40" /></span> 一键 SOS 紧急求助 </button>警报音用 Web Audio API 实时合成——两个锯齿波振荡器 5Hz 失谐产生"警笛"质感,频率在 600-1200Hz 之间正弦扫描:
// composables/useAlertSound.tsconstosc1=ctx.createOscillator()// 600Hzconstosc2=ctx.createOscillator()// 605Hz,5Hz 失谐 = 警笛质感setInterval(()=>{constfreq=600+Math.sin(Date.now()/200)*300osc1.frequency.value=freq},60)后端:收到 SOS 后通过 RabbitMQ 广播 → 消费者查询所有认证志愿者 + 管理员 → 批量创建通知 + WebSocket 推送 + 家属短信,三重保障。
2.4 发起求助 + 语音输入
老人可发起四种求助:陪诊、代购、探访、家政。流程三步:选类型 → 填描述 → 确认。描述支持语音输入,不需要打字。
语音链路:浏览器 AudioWorklet 录音(16kHz) → 手写 WAV 编码(44 字节 RIFF 头) → 上传后端 → 阿里云 NLS 识别 → 返回文字。
// composables/useVoiceRecorder.tsfunctionencodeWAV(samples:Float32Array,sampleRate:number):ArrayBuffer{constbuffer=newArrayBuffer(44+samples.length*2)constview=newDataView(buffer)// RIFF 头:'RIFF' + 文件大小 + 'WAVE'// fmt chunk:PCM, 1 channel, sampleRate, 16-bit// data chunk:Float32 → Int16 转换writeString(view,0,'RIFF')view.setUint32(4,36+samples.length*2,true)writeString(view,8,'WAVE')// ...}2.5 健康管理
三个子模块:
用药提醒:老人添加药品名、剂量、时间槽(如["08:00", "12:00", "18:00"]),后端每分钟定时生成打卡任务,超时 30 分钟自动标记"未完成"并短信通知紧急联系人。打卡使用tuple_复合键批量查询,避免 N+1。
健康指标:血压、血糖、血氧、心率、体重、体温六项录入,异常值自动标红。
健康档案:慢病标签、过敏史、用药史、紧急联系人。
2.6 健康商城
老人端内置了一个完整的健康商品商城,商品按慢病标签智能推荐。
商城首页:五大分类(慢病食品/康复辅具/监测设备/营养保健/适老家居),分类切换 + 智能推荐双区展示。商品卡片标注适配人群(如"适配高血压"“糖尿病推荐”),让老人一眼看懂适不适合自己。
<!-- Mall.vue — 分类 + 商品网格 --> <div class="cat-grid"> <button v-for="c in categories" @click="selectCategory(c.code)"> <Apple :size="28" /> 慢病食品 </button> <!-- 康复辅具 / 监测设备 / 营养保健 / 适老家居 --> </div> <div class="goods-grid"> <BaseCard v-for="g in categoryProducts" @click="goProduct(g.id)"> <img :src="g.cover" /> <span>{{ g.name }}</span> <span>¥{{ g.price }}</span> <span class="tag" :class="tagColor(g)">{{ tagText(g) }}</span> </BaseCard> </div>商品搜索使用Elasticsearch而非 MySQL LIKE,支持 IK 中文分词。ES 只存搜索字段(名称、描述、分类、标签),完整数据仍在 MySQL,搜索结果用 ES 命中 ID 回 MySQLIN查询,避免双写一致性问题。
# es_client.py — 商品搜索body={"query":{"multi_match":{"query":keyword,"fields":["name^3","description"]# 名称权重 3 倍}}}商品详情:展示价格、库存、慢病适配标签,支持数量选择 + 加入购物车 + 立即购买。
购物车:全选/单选切换、数量加减、实时计算总价、一键结算。
支付:对接支付宝沙箱环境。个人开发者无营业执照,沙箱可完整模拟支付流程,上线前替换密钥和网关即可切换正式环境。踩坑:沙箱要求 RSA2 密钥为 PKCS#1 格式(openssl genrsa),不能用 PKCS#8。
# 支付宝电脑网站支付alipay=AliPay(appid=settings.alipay_app_id,app_private_key_string=settings.alipay_private_key,alipay_public_key_string=settings.alipay_public_key,sign_type="RSA2",debug=settings.alipay_sandbox# 沙箱模式)2.7 AI 健康助手 + 反诈查验
两个 AI 功能都基于 DeepSeek API。
健康助手:携带老人健康档案上下文(脱敏后:年龄、慢病、过敏史、用药)进行问答。System Prompt 严格限制——绝不给出医疗诊断、绝不建议停药换药,回复末尾强制加免责声明。
反诈查验:老人收到可疑短信,口述内容 → 语音转文字 → AI 分析风险等级(高/中/低)+ 结论 + 提醒。全程零打字,结果自动 TTS 语音播报。
# 反诈 System Prompt 核心约束""" 你是反诈分析专家。分析用户输入,判断是否为诈骗。 输出 JSON:{ risk_level: "高风险"|"中风险"|"低风险", conclusion, reminder } 常见特征:冒充公检法、要求转账、中奖通知、陌生链接 """三、志愿者端:抢单 + 信用分游戏化激励
志愿者端的设计核心是让接单有成就感——我设计了一套类似游戏段位的信用分等级体系。
3.1 信用分勋章卡
首页顶部是一张金色渐变勋章卡,视觉上就给人"这是我的荣誉"的感觉:
<!-- 金色渐变背景 + 装饰光晕 + 大号分数 --> <div class="medal-card" style="background: linear-gradient(135deg, #FBBF24, #D97706, #B45309)"> <Crown :size="18" /> 我的成就 <span class="score-num">245</span>分 <!-- 等级徽章 --> <TierBadge tier="silver" size="xl" /> <!-- 升级进度条 --> <div class="progress-bar"> <div class="fill" :style="{ width: '48%' }" /> 距 💎 黄金 还差 55 分 </div> <!-- 底部三栏统计 --> 32h 服务时长 | 94% 好评率 | 本月 +35 </div>3.2 四级信用分体系
# services/credit_service.pyTIER_NAMES={"bronze":"青铜","silver":"白银","gold":"黄金","diamond":"钻石"}defget_tier_by_score(score:int)->str:ifscore<=99:return"bronze"elifscore<=299:return"silver"elifscore<=599:return"gold"return"diamond"积分来源:完成订单(+10)、五星好评(+3)、响应 SOS(+20)、连续接单(+5)、完成培训(+8)。扣分项:抢单后取消(-10)、差评(-5)。
3.3 抢单流程
待接单列表 → 点击"立即抢单" → confirm('确认接单?') → 确认 → grabOrder(id) → 刷新列表 → 跳转任务详情抢单是并发冲突最激烈的场景——多个志愿者可能同时抢同一订单。我用双层锁方案:Redis 分布式锁(优先)+ MySQLSELECT FOR UPDATE行锁(回退)。
# services/order_service.pyasyncdefgrab_order(db,order_id,volunteer_id):try:# 第一层:Redis 锁(SET NX EX + Lua 原子释放)asyncwithredis_client.lock(f'order:grab:{order_id}',ttl=10):returnawaitself._do_grab(db,order_id,volunteer_id)exceptException:# 第二层:Redis 挂了回退到 DB 行锁returnawaitself._do_grab_db_lock(db,order_id,volunteer_id)3.4 Tab 切换 + 实时同步
三个 Tab(待接单/进行中/已完成),每个带计数徽章。页面通过 WebSocket 订阅order、sos、credit三个 key,订单状态变更自动刷新:
onMounted(async()=>{awaitloadAllData()register('order',loadAllData)// 订单变化 → 刷新register('sos',loadAllData)// SOS 变化 → 刷新register('credit',loadAllData)// 信用变化 → 刷新})四、管理后台:数据看板 + 工单审核 + 全模块管理
管理员端面向社区工作人员,核心是数据驱动决策和全流程管理。
4.1 数据看板
页面顶部 6 个 StatCard 指标卡片(老人数/志愿者数/今日求助/待处理预警/商城GMV/平均信用),待处理预警卡带红色呼吸光晕动画。数据通过 4 个 API 并行加载:
const[statsRes,healthRes,trendRes,eventsRes]=awaitPromise.all([getDashboardStats(),// 核心指标getHealthDistribution(),// 健康画像getServiceTrend(30),// 30 天趋势getRecentEvents(20),// 实时事件])纯 CSS 柱状图(30 天服务趋势):30 列网格,每列两个柱子(求助数/完成数),高度按比例计算,无第三方图表库依赖。
SVG 环形图(慢病分布):用stroke-dasharray和stroke-dashoffset实现环形图,中心显示老人总数,外围按慢病类型分段着色。
实时事件流:SOS(红)/健康(黄)/订单(绿)/审核(灰)/信用(蓝) 五种类型,带已读/未读状态,通过 WebSocket 实时推送,管理员不需要手动刷新。
4.2 工单审核
老人提交的普通求助进入PENDING_REVIEW状态,管理员在工单列表页逐条审核:
<!-- 状态筛选栏:待审核 / 待接单 / 已接单 / 已完成 / 已取消 --> <div class="status-bar"> <button v-for="s in statusList" :class="{ active: activeStatus === s.value }"> {{ s.label }} </button> </div> <!-- 审核弹窗:展示工单号、类型、老人、描述,通过/拒绝 --> <BaseDialog v-model="reviewDialog" title="工单审核"> <BaseButton @click="review(true)">✅ 审核通过</BaseButton> <BaseButton @click="review(false)" variant="danger">❌ 拒绝</BaseButton> </BaseDialog>审核通过 →PENDING(待抢单),通知志愿者可接单;审核拒绝 →CANCELLED。
4.3 用户管理
老人管理和志愿者管理各一个列表页,支持手机号/姓名搜索。列表展示关键字段:手机号、姓名、社区、状态(正常/禁用/锁定)、注册时间。管理员可启用/禁用账号。
4.4 商品管理
管理员可对商城商品做完整 CRUD:
// 商品表单字段constformData=ref({sku:'',name:'',category:'',brand:'',cover:'',description:'',tags:[],// 通用标签for_diseases:[],// 慢病适配(高血压/糖尿病/冠心病等)price:0,stock:0,status:'on_sale',is_hot:false,is_new:false,is_recommend:false})商品分类支持 5 类(慢病食品/康复辅具/监测设备/营养保健/适老家居),慢病标签可选 6 种(高血压/糖尿病/冠心病/高血脂/关节炎/骨质疏松),标签决定商城首页的推荐逻辑。商品状态支持上架/下架切换。
4.5 信用管理 + 系统设置
信用管理:查看每个志愿者的当前信用分、等级、流水记录,管理员可手动调整信用分(上限 500 分/次),调整原因必填。
系统设置:字典表管理(如服务类型、慢病标签等枚举值可在线配置,无需改代码),为后续多社区 SaaS 化预留扩展点。
五、项目踩坑记录(6 个真实问题)
问题一:Spug 短信发不出去
现象:注册时短信验证码始终发送失败。
根因:Spug 推送服务要求调用方 IP 必须在后台白名单中,开发机出口 IP 未被信任。
解决:登录 Spug 控制台 → 添加服务器公网 IP 到白名单 → 即时生效。
教训:用第三方 SaaS 服务先读安全策略文档,IP 白名单是最常见的坑。
问题二:三端订单不互通
现象:老人发了 SOS,志愿者端看不到;志愿者抢了单,老人端不知道。
根因:初期各端靠轮询数据库获取状态,延迟 5-10 秒,且跨角色通知缺失。
解决:引入RabbitMQ 消息队列 + WebSocket 实时推送:
老人发起 SOS → RabbitMQ 发布到 sos.dispatch 队列 → 消费者查询所有志愿者 + 管理员 → 批量创建站内通知(落 DB) → WebSocket 实时推送到在线用户 → 短信通知家属改造后通知延迟从 5-10 秒降到 1 秒以内。MQ 连接使用aio_pika.connect_robust自带断线重连,所有订阅记录在案,连接重建后自动重新注册。
问题三:虚拟机部署导致页面加载 3-5 秒
现象:MySQL、Redis、RabbitMQ、ES 都跑在虚拟机上,每次请求多次跨网络通信,延迟叠加后首页加载 3-5 秒。
解决:开发阶段把所有中间件迁到本地127.0.0.1:
| 指标 | 虚拟机 | 本地 | 提升 |
|---|---|---|---|
| 首页加载 | 3-5s | < 500ms | 6-10x |
| 商品搜索 | 2-3s | < 300ms | 7-10x |
| SOS 广播 | 2-5s | < 500ms | 4-10x |
问题四:抢单并发冲突
现象:两个志愿者同时抢同一订单,后者覆盖前者,一单被两人抢到。
解决:Redis 分布式锁(优先)+ MySQL 行锁(回退)双层保障。
asyncdefgrab_order(db,order_id,volunteer_id):try:asyncwithredis_client.lock(f'order:grab:{order_id}',ttl=10):returnawaitself._do_grab(db,order_id,volunteer_id)exceptException:returnawaitself._do_grab_db_lock(db,order_id,volunteer_id)# SELECT FOR UPDATE关键设计:Lua 脚本释放锁(只有持有者才能删锁),10 秒 TTL 防死锁,自旋等待最多 5 秒。
问题五:无支付资质 → 支付宝沙箱
现象:个人开发者无营业执照,无法申请正式支付宝商户。
解决:支付宝沙箱环境,个人账号即可开通,完整模拟支付流程。上线前替换密钥和网关地址即可切换正式环境。
额外踩坑:沙箱要求 RSA2 密钥必须是 PKCS#1 格式(openssl genrsa),不能用 PKCS#8。
问题六:浏览器语音识别不准
现象:初期用浏览器 Web Speech API,中文准确率 70-85%,老年人发音稍含糊就识别错误,且 Firefox/Safari 支持差。
解决:改为浏览器只负责录音,上传到后端由阿里云 NLS 识别:
浏览器 AudioWorklet 录音(16kHz) → 手写 WAV 编码(44 字节 RIFF 头) → POST /speech/recognize(FormData 上传) → 三级格式检测(Content-Type → 扩展名 → Magic Bytes) → 非 WAV 格式 ffmpeg 转码 → 阿里云 NLS 一句话识别(HMAC-SHA1 签名) → 返回文本(准确率 95%+,支持标点预测和数字转写)额外踩坑:阿里云 NLS 与 httpx 有 TLS 兼容问题,改用subprocess调 curl 绕过。
六、项目复盘
做得好的
- 适老化是系统工程:字号、对比度、触控尺寸、语音输入,是完整的设计语言而非简单放大
- 抢单双层锁:Redis 优先 + MySQL 回退,兼顾性能和可靠性
- MQ 订阅自动恢复:连接断开重连后自动重注册,运维省心
- 三端主题 CSS 变量体系:通过 className 切换,零运行时开销
- AI Prompt 打磨:健康助手严格限制不越界,反诈分析结构化输出
待改进
- 缺单元测试(核心业务逻辑需要覆盖)
- Token 过期直接跳登录,应该静默刷新
- 没有 LBS 附近匹配(志愿者目前全局匹配)
- ES 和 MySQL 数据一致性靠应用层保证,理想方案是 Canal 监听 binlog
项目代码完全开源,欢迎 Star 和交流。
本文由 风吹夏回 原创,发布于 CSDN,转载请注明出处。