淘过气的领养系统,踩过技术选的坑:宠物领养管理系统与志愿者募捐双模块实战笔记
做这类系统的同行都知道,宠物领养管理系统和志愿者募捐系统这类题目,看起来就是个普通的后台管理项目,真正动手才会发现,里面的角色关系、业务状态流转、还有募捐资金流那一块,都是需要提前想清楚的硬骨头。最近我把这套东西完整落地了一遍,前端用Vue,后端在Flask和Django之间做了取舍,IDE全程PyCharm,过程中踩了不少值得记录的坑,今天把它们整理出来。
这套系统适合正在做课程设计、毕业设计,或者想练手全栈项目的人参考,尤其是那种“领养+募捐”双核心、还带志愿者管理的组合型系统,思路和代码细节都能直接复用。我先把整体的设计思路、框架选型逻辑、核心功能拆解、实操过程还有问题排查依次讲清楚,尽量让看完的人能自己动手搭一套出来。
1. 项目整体设计与思路拆解
1.1 先看懂这个项目的真实需求
宠物领养管理系统,核心不是“展示宠物照片”那么简单。它背后是一套完整的业务闭环:
- 宠物从哪里来:通常是救助站、个人送养、管理员后台录入三种来源。
- 宠物处于什么状态:待领养、已被申请、已领养、暂时离世或找回等。
- 领养人怎么申请:填写领养申请,提交个人资料、居住环境、养宠经验。
- 谁来审核:管理员或志愿者初审、回访、终审。
- 领养之后如何跟进:回访记录、疫苗提醒、绝育记录等。
志愿者募捐系统则是另一条业务线,它要处理的不是简单的“有人捐了钱”,而是:
- 募捐项目的发布与管理:谁发起的、目标金额是多少、截止什么时间。
- 捐赠流程:用户在线捐赠,生成捐赠记录。
- 资金透明化:每一笔募捐的累计金额、支出明细、剩余金额需要可追踪。
- 志愿者管理:志愿者注册、审核、服务时长记录、参与活动报名。
这两条业务线之间还存在交叉关系,比如领养审核可以由志愿者参与,募捐活动需要志愿者协助执行。所以权限系统不能简单做成“管理员/普通用户”两级,至少要有“超级管理员、内容管理员、志愿者、普通用户”四类角色。
1.2 技术选型里的关键取舍逻辑
这个项目标题里同时出现了 Flast 和 Django,这是典型的两难场景。实际上这两个框架在真实项目里很少混着用,标题把两个关键词都写上,通常说明需求方一开始没想清楚用哪个,或者是照着某个方向先调研了一遍。
我的做法是:主体业务用 Flask 实现,Django 的设计思想做参考,但不再引入第二套框架。原因有三:
- Flask 轻量,路由和视图逻辑清晰,对于这类中等复杂度的业务系统,代码量可控,调试方便。
- Django 的 ORM、Admin 后台很强大,但项目本身并不需要那么重的内置功能,硬套 Django 反而会增加学习成本和部署体积。
- 前端用了 Vue 之后,前后端完全分离,后端只需要提供 API 接口,Flask 配合蓝图Blueprint就能组织得很好。
提示:如果你的项目是毕业设计标题已经定了“基于Django”,那也别慌,把下面的业务逻辑原样搬过去,只是路由写法和ORM语法换一下,工作量大概增加20%,不会伤筋动骨。
1.3 为什么前端选 Vue
前后端分离架构下,Vue 是目前入门最快、生态最完善的选择之一。对于这套系统,前端不需要太重,几个核心页面就够了:
- 前台首页(宠物列表+募捐列表)
- 用户登录注册页
- 宠物详情页与领养申请页
- 募捐详情页与捐赠支付页
- 用户中心(我的申请、我的捐赠、我的志愿者申请)
- 后台管理页(宠物管理、领养审核、募捐管理、志愿者审核)
用 Vue Router 管路由,Vuex 或 Pinia 管登录态和用户信息,Axios 统一请求后端接口。重点是不需要引入太多UI框架,自己用 Bootstrap 或 Element 写样式完全够用,避免过度工程化。
2. 核心细节拆解与实操要点
2.1 数据库设计:一张关系图决定系统上限
这套系统最核心的资产是数据,表结构设计是否合理,直接决定了后面开发的顺畅程度。我按模块拆成六组核心表:
用户与权限
- user:id、username、password_hash、role(admin/volunteer/user)、real_name、phone、avatar、create_time
- volunteer_apply:id、user_id、status(pending/approved/rejected)、reason、audit_time
宠物管理
- pet:id、name、species(cat/dog/other)、breed、gender、age_month、health_status、vaccine_status、sterilized、photo_url、description、status(available/applying/adopted/other)、shelter_id(救助站来源)、create_time
- adoption_application:id、pet_id、user_id、apply_reason、has_experience、house_type、is_rented(租房与否则需要房东同意)、status(pending/first_review/approved/rejected/completed)、apply_time、audit_remark
募捐管理
- donation_project:id、title、description、cover_url、target_amount、raised_amount、status(ongoing/ended)、start_time、end_time、organizer_id
- donation_record:id、project_id、user_id、amount、pay_method、status(paid/unpaid/refunded)、donate_time、message
支出管理
- expense_record:id、project_id、item_name、amount、expense_type、operator_id、remark、happen_time
活动与回访
- volunteer_activity:id、title、description、location、start_time、end_time、need_people
- activity_signup:id、activity_id、user_id、status、signup_time
- follow_up_record:id、adoption_id、user_id、content、next_follow_date、create_time
这张设计的核心逻辑是:把“领养”和“募捐”作为两条相互独立又通过“志愿者活动”和“回访记录”产生关联的业务线。比如志愿者既可以去审核领养申请,也可以报名参加募捐线下活动,这些行为都通过中间表跟用户关联。
2.2 状态流转设计是这类系统的灵魂
宠物领养管理系统的难点不在增删改查,而在“状态”的变化。
一只宠物从录入到被领养,至少要经历:
- available(可领养)
- applying(被申请,锁定状态,避免多人同时申请)
- adopted(已领养)
- completed(完成回访,归档)
用户申请领养的流程,也对应不同状态:
- pending(提交申请,等待初审)
- first_review(志愿者初审通过,进入复审)
- approved(管理员最终通过,可以线下接宠)
- rejected(未通过)
- completed(领养完成)
我在设计接口时遵循一个原则:状态只能向后流转,不能回退(除非管理员手动操作)。比如 pending 的申请不能直接改成 approved,必须先走 first_review。这样做的目的是防止业务逻辑被绕过,前端页面再怎么调接口也跳不过审核环节。
2.3 募捐资金流设计:钱的透明度不能糊弄
募捐模块如果只做“用户捐钱、后台看总数”就太单薄了,实际做的时候一定要把“资金生命周期”做出来,这既是对用户负责,也是让项目答辩时拿得出手的关键点。
资金生命周期分三段:
- 募集期:用户查看募捐项目,贡献金额,系统累计金额。
- 使用期:管理员或志愿者录入支出记录,比如购买猫粮、支付绝育手术费用、驱虫药采购等。
- 结项期:达到目标金额或到期停止募捐,公示总金额和支出总额,剩余资金转入平台备用金或用于其他项目。
费用记录要按项目id关联,不能只记一个总支出。否则用户看到募捐明细时,完全无法追溯这笔钱花在了哪里,那系统就失去了“透明”的意义。
注意:实际开发中,支付推荐接入第三方的沙箱测试环境,不要自己去处理资金通道,也不要真的收款。项目展示环节用模拟支付回调就能满足需求,记录支付状态时可提供“模拟成功”按钮,标注清楚仅用于演示。
3. 实操过程与核心功能模块实现
3.1 环境搭建:PyCharm 下 Flask 后端初始化
PyCharm 里新建 Flask 项目,我习惯把项目结构调整成下面的样子:
pet_adoption/ ├── app.py # 应用工厂 ├── config.py # 配置管理 ├── requirements.txt ├── extensions.py # db, ma, jwt 等扩展实例 ├── models/ # SQLAlchemy 模型 │ ├── __init__.py │ ├── user.py │ ├── pet.py │ ├── adoption.py │ ├── donation.py │ └── volunteer.py ├── api/ # 蓝图接口 │ ├── __init__.py │ ├── auth.py │ ├── pets.py │ ├── adoptions.py │ ├── donations.py │ └── volunteers.py ├── utils/ # 工具函数 │ ├── auth_decorator.py │ ├── response.py │ └── upload.py └── migrations/ # 数据库迁移关于数据库选型,本地开发建议用 SQLite,部署演示再用 MySQL。SQLite 零配置,拿过来就能跑,等系统稳定后再换 MySQL 只需要改一处连接字符串和重新迁移数据,成本很低。
开发环境推荐 Python 3.10 及以上,Flask 2.3 以上。依赖安装:
pip install flask flask-sqlalchemy flask-migrate flask-cors flask-jwt-extended marshmallow pip install pymysql # 如果用MySQL3.2 后端核心接口设计与代码实现
3.2.1 JWT登录与权限校验
用户体系我用 JWT 做无状态认证,比 Session 更适合前后端分离。登录接口返回 access_token,前端存到 localStorage,请求时放在请求头里。
from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity @auth_bp.route('/login', methods=['POST']) def login(): data = request.get_json() username = data.get('username') password = data.get('password') if not username or not password: return response_error('用户名和密码不能为空') user = User.query.filter_by(username=username).first() if not user or not check_password_hash(user.password_hash, password): return response_error('用户名或密码错误') token = create_access_token(identity=str(user.id)) return response_success({ 'token': token, 'user_info': { 'id': user.id, 'username': user.username, 'role': user.role, 'avatar': user.avatar } })权限控制写成装饰器,方便复用:
def role_required(*roles): def wrapper(fn): @wraps(fn) @jwt_required() def decorator(*args, **kwargs): user_id = get_jwt_identity() user = User.query.get(int(user_id)) if not user: return response_error('用户不存在') if user.role not in roles: return response_error('权限不足'), 403 return fn(*args, **kwargs) return decorator return wrapper用法:
@veterans_bp.route('/apply', methods=['POST']) @role_required('admin', 'volunteer') def review_adoption(): ...3.2.2 宠物列表与状态过滤
宠物列表接口是最常被访问的接口,需要考虑筛选和分页。前端传参数 species、status、keyword,后端用 SQLAlchemy 条件查询:
@pet_bp.route('/list', methods=['GET']) def pet_list(): page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 12, type=int) species = request.args.get('species') status = request.args.get('status') keyword = request.args.get('keyword', '') query = Pet.query if species and species != 'all': query = query.filter_by(species=species) if status and status != 'all': query = query.filter_by(status=status) if keyword: query = query.filter(Pet.name.like(f'%{keyword}%')) pets = query.order_by(Pet.create_time.desc()).paginate( page=page, per_page=per_page, error_out=False ) # 序列化后返回实操心得:分页建议用 paginate 而不是自己写 offset/limit,Flask-SQLAlchemy 的 paginate 自带总页数、总数等元数据,前端做分页器直接拿 total 和 pages 就行,省掉很多重复工作。
3.2.3 领养申请的双重审核
这个接口是业务核心。用户提交领养申请时,系统需要同时做三件事:
- 校验宠物是否可领养(status必须是available)。
- 将宠物状态置为 applying。
- 创建领养申请记录。
这三个必须在一个数据库事务里完成,否则会出现用户A申请成功但宠物状态还是available、用户B也能提交申请的并发问题。
from sqlalchemy import and_ @adoption_bp.route('/apply', methods=['POST']) @jwt_required() def apply_adoption(): user_id = int(get_jwt_identity()) data = request.get_json() pet_id = data.get('pet_id') pet = Pet.query.filter_by(id=pet_id).with_for_update().first() if not pet: return response_error('宠物不存在') if pet.status != 'available': return response_error('该宠物已被申请或已领养') # 检查是否重复申请 existing = AdoptionApplication.query.filter( and_(AdoptionApplication.pet_id == pet_id, AdoptionApplication.user_id == user_id, AdoptionApplication.status.in_(['pending', 'first_review'])) ).first() if existing: return response_error('you已有处理中的申请,请勿重复提交') application = AdoptionApplication( pet_id=pet_id, user_id=user_id, apply_reason=data.get('apply_reason'), has_experience=data.get('has_experience', False), house_type=data.get('house_type', ''), is_rented=data.get('is_rented', False) ) pet.status = 'applying' db.session.add(application) db.session.commit() return response_success('申请提交成功')提交申请后,志愿者端显示待审核列表。志愿者通过初审,管理员做最终审核,通过后把宠物状态改为adopted。
3.2.4 募捐模块的金额更新与事务
募捐的核心接口是“用户捐赠成功后,需要更新项目的已募捐金额”。如果用户刷新页面发现钱捐了但金额没变,那就尴尬了。
更好的方式是把更新累计金额放在支付回调里做,而不是前端捐赠成功后立即做。因为前端点击“模拟支付成功”只是回调请求,万一被重复调用,金额会叠加多次。我用的方式是:在回调接口里查询该捐赠记录的状态,只有状态从 unpaid 转为 paid 时,才执行累计金额更新。
@donation_bp.route('/callback', methods=['POST']) def donation_callback(): data = request.get_json() record_id = data.get('record_id') record = DonationRecord.query.filter_by(id=record_id).with_for_update().first() if not record: return response_error('记录不存在') if record.status == 'paid': # 防止重复回调 return response_success('重复回调,已忽略') project = DonationProject.query.filter_by(id=record.project_id).with_for_update().first() record.status = 'paid' project.raised_amount += record.amount db.session.add_all([record, project]) db.session.commit() return response_success('支付成功')这里用 with_for_update() 加行锁,是为了避免两个并发回调同时读到旧的 raised_amount,导致金额丢失。
3.3 前端 Vue 项目搭建与接口对接
3.3.1 项目初始化与跨域问题
用 Vue CLI 创建项目后,第一件事就是配置 Axios 和跨域代理。开发环境下让 Vue 开发服务器代理 API 请求到 Flask,能避开跨域问题。
在vue.config.js里配置:
module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://127.0.0.1:5000', changeOrigin: true } } } };后端同时开启 CORS:
from flask_cors import CORS CORS(app, supports_credentials=True, resources={r"/api/*": {"origins": "*"}})这里有个细节:开发环境下用代理就够了,部署到生产环境时前端构建出的静态文件由后端统一托管,就不存在跨域问题。所以不要过度纠结CORS配置,能跑通开发流程再说。
3.3.2 登录态管理与路由守卫
Vuex 存用户信息和token,路由守卫里判断是否登录:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next('/login'); } else if (to.meta.requiresAdmin && localStorage.getItem('user_role') !== 'admin') { next('/'); } else { next(); } });注意:前端的路由守卫只是用户体验层面的限制,真正的权限校验必须在后端接口做。前端可以直接改代码绕过路由守卫,但后端@role_required是绕不过去的。
3.3.3 宠物图片上传
宠物图片上传是少不了的。我的方案是:Flask 后端提供/api/upload接口,接收 multipart/form-data 格式的图片,保存到服务器static/uploads目录,返回可访问的 URL。
@upload_bp.route('/upload', methods=['POST']) @jwt_required() def upload_image(): file = request.files.get('file') if not file: return response_error('没有文件上传') # 校验类型 if file.filename.split('.')[-1].lower() not in ['jpg', 'jpeg', 'png', 'gif', 'webp']: return response_error('不支持的图片格式') filename = f"{uuid.uuid4().hex}.{file.filename.split('.')[-1].lower()}" upload_path = os.path.join(current_app.config['UPLOAD_FOLDER'], filename) os.makedirs(current_app.config['UPLOAD_FOLDER'], exist_ok=True) file.save(upload_path) url = f"/static/uploads/{filename}" return response_success({'url': url})文件名一定要用 uuid 重命名,不能用用户上传的原始文件名,否则会出现重名覆盖、非法字符等问题,这是很多新手会踩的坑。
4. 常见问题与排查技巧实录
4.1 前端报错 Unexpected token<in JSON
这个报错几乎每个做前后端分离的人都遇到过。原因很简单:后端返回的不是 JSON,而是 HTML 错误页。
打开 F12 看 Network 里该请求的 Response,如果返回的是 404 HTML 页面,说明前端请求的 URL 跟后端路由不匹配。比如 Vue 代理把/api/pets/list转发了,但 Flask 蓝图注册的是/api/pets/list/带斜杠,就可能匹配失败。
排查步骤:
- 确认后端蓝图的 url_prefix 和路由拼接后,跟前端请求地址完全一致。
- 直接在后端测试接口返回:在浏览器访问该 URL,看是否返回 JSON。
- 如果是 POST 请求,确认前端是否设置了正确的 Content-Type。
4.2 SQLAlchemy 报错Object already attached to session
这个是在序列化模型时容易踩的坑。比如在请求中查询出 Pet 对象,返回时直接jsonify(pet.__dict__),就会报错或者出现多余字段。
我的解决方案是在模型里定义 to_dict 方法:
class Pet(db.Model): __tablename__ = 'pet' ... def to_dict(self): return { 'id': self.id, 'name': self.name, 'species': self.species, 'breed': self.breed, 'gender': self.gender, 'age_month': self.age_month, 'health_status': self.health_status, 'vaccine_status': self.vaccine_status, 'sterilized': self.sterilized, 'photo_url': self.photo_url, 'description': self.description, 'status': self.status, 'create_time': self.create_time.strftime('%Y-%m-%d %H:%M:%S') }这个方法的优点是可以自由控制输出字段,不会把密码哈希、内部字段暴露给前端,同时时间字段也统一转了格式,避免前端解析时分不一致。
4.3 宠物状态更新后列表没刷新
前端页面在申请领养成功后,宠物详情页会显示“已申请”,但返回列表页发现宠物还在“可领养”分类里。这是前端组件状态不同步导致的,不是后端问题。
解决方案有两个:
- 提交申请成功后,重新拉取列表接口数据。
- 列表页在 created 钩子里重新调用 API,不要只依赖路由参数。
我建议用第二种,因为即使用户手动刷新页面,数据也会正确加载。
4.4 Flask 中的@login_required和@jwt_required混用报错
有人在 Flask 项目里用 Flask-Login 的@login_required同时用 Flask-JWT-Extended 的@jwt_required,两者会互相干扰。像这套系统用了 JWT 之后,就不要再用 Flask-Login。Flask-Login 的上下文基于 session,JWT 基于 token,混在一起容易出现“登录了还是提示未登录”的问题。
原则就是:一个项目里只保留一套认证机制。前后端分离项目优先选 JWT。
4.5 捐款金额小数点精度问题
很多人用 Float 字段存金额,这是个隐患。Python 的 Float 是双精度浮点数,在累加时会产生精度误差。募捐金额必须用 Numeric(10, 2) 类型:
class DonationRecord(db.Model): amount = db.Column(db.Numeric(10, 2), nullable=False)这样在 SQLite 里存的是小数,MySQL 里存的是 DECIMAL(10, 2),累加结果精确到分,不会出现 0.1 + 0.2 = 0.30000000000000004 那种问题。
4.6 Axios 请求拦截器统一处理 401
当 token 过期时,后端会返回 401,前端每个接口单独处理太麻烦。在 Axios 拦截器里做统一处理是标准的做法:
axios.interceptors.response.use( response => response, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); localStorage.removeItem('user_role'); window.location.href = '/login'; } return Promise.reject(error); } );这样做的好处是,token 过期后自动跳转到登录页,用户体验友好,不用手动清缓存。
4.7 PyCharm 运行 Vue 项目的配置
PyCharm 开发这种项目,要同时跑两个进程:Flask 后端(端口5000)和 Vue 前端(端口8080)。很多人在 PyCharm 里只启动 Flask,然后访问 8080 发现打不开页面。
推荐在 PyCharm 的 Run Configuration 里配置两个运行配置:
- Flask 运行:选择 app.py,端口默认 5000。
- npm 运行:Command 填写
run serve。
然后在浏览器里访问http://localhost:8080,Vue 开发服务器会把 API 请求代理到 5000 端口。
如果觉得两个进程切换麻烦,也可以直接把 Vue 的npm run build产物放到 Flask 的 static 目录,用 Flask 直接托管前端页面,避免同时开两个进程。不过开发调试阶段还是建议分开跑,改动代码后热更新时间更短。
5. 实操总结与扩展思路
我整理一个核心模块与业务的映射参考表,方便后续开发时对照:
| 功能模块 | 前端页面 | 后端接口 | 核心数据表 | 权限要求 |
|---|---|---|---|---|
| 用户登录注册 | /login, /register | POST /api/auth/login, POST /api/auth/register | user | 公开/登录 |
| 宠物浏览 | /pets, /pet/:id | GET /api/pets/list, GET /api/pets/detail | pet | 公开 |
| 领养申请 | /apply | POST /api/adoptions/apply | adoption_application | 登录用户 |
| 领养审核 | /admin/audit | GET /api/adoptions/pending, PUT /api/adoptions/review | adoption_application | admin/volunteer |
| 募捐项目列表 | /donate | GET /api/donations/list | donation_project | 公开 |
| 在线捐赠 | /donate/:id | POST /api/donations/donate | donation_record | 登录用户 |
| 募捐支出管理 | /admin/expense | POST /api/donations/expense | expense_record | admin |
| 志愿者活动 | /activities | GET /api/volunteers/activities | volunteer_activity | 公开/登录 |
| 回访记录 | /admin/followup | POST /api/followups | follow_up_record | admin/volunteer |
这套系统后续可以继续扩展的方向,我个人认为最有价值的是两个:
一是增加消息通知机制,比如用户提交领养申请后,审核结果出来时给用户发短信或邮件通知。这个用 Celery + Redis 异步任务队列可以实现,不至于让主线程阻塞。
二是引入更完整的业务报表模块,比如领养成功率按宠物品种统计、募捐资金支出分类占比图、志愿者活跃度排行等,用 ECharts 画图表展示。
这两个方向无论是放到课程设计加分答辩,还是上线做真实运营,都是比较实用的功能。
最后分享一个我在实际开发中的体会:做这类管理系统,编码的难度其实不高,真正花时间的部分是业务流程的梳理和状态机的设计。先把领养、募捐各自的流程图画明白,把每个状态之间的流转关系列清楚,后端接口写起来就是水到渠成的事。如果一开始就急着写代码,后面改起状态和字段来,返工的成本远比想象的高。希望这篇笔记能帮准备做类似系统的人少走几个弯路,尤其是那些一开始就被 Flask 和 Django 的选型搞纠结的朋友,先动手把业务流程跑通,比什么都强。