“宝贝回家”这几个字,对做技术的人来说,不应该只是新闻里的感人故事。它背后是一个极其典型的 Web 全栈实战场景:地理位置、图片存储、模糊搜索、状态流转、消息通知,全部都在一个小程序里。用 Flask 做后端,配合微信小程序端,既能快速落地,又能把核心逻辑讲清楚,特别适合作为个人作品集项目,或者作为毕业设计、公益项目原型。
我之前断断续续花了三周左右,从零把这样一个寻亲信息发布与线索上报的小程序完整跑通。这套东西看着简单,里面坑其实不少,尤其是微信小程序的各种限制和 Flask 部署时的路径问题。这篇文章我把整个项目的设计思路、数据库表结构、后端 API 实现、小程序端页面逻辑,包括我踩过的坑,全部拆开写清楚。代码不一定是最优解,但每一步都是能直接跑通的。
1. 项目整体设计与技术选型思路
1.1 为什么选 Flask,而不是 Django 或 Node.js
做这类信息管理型的小程序后端,很多人的第一反应是 Django,自带 Admin 后台,ORM 也强大。但我的选择是 Flask,原因很实际:
第一,项目规模可控。寻亲小程序的核心业务就是信息的增删改查、图片上传、模糊搜索、线索上报,不涉及复杂的权限体系。Flask 的轻量正好匹配,不需要 Django 那一套完整的 MTV 模型和中间件机制。
第二,上手门槛低,方便二次开发。如果你是在校学生或者刚转行做全栈,Flask 的源码量比 Django 小得多,你能完整读懂每一个请求从进入到返回的路径,这对调试和面试讲项目都特别重要。
第三,部署灵活。Flask 应用就是一个 Python 进程,用 Gunicorn 或者直接python app.py都能跑,配合 Nginx 反代也很简单。小程序要求的 HTTPS 域名配置,处理起来比 Node.js 那套 PM2 方案直观很多。
注意:如果你的项目后续要加复杂的用户角色权限、内容审核工作流,再考虑升级到 Django 或加 Flask-Admin 插件。前期用 Flask 快速出原型,永远是性价比最高的路径。
1.2 前后端分离架构:小程序端 + Flask API
项目的整体架构是前后端分离的。微信小程序作为客户端,负责页面展示和用户交互;Flask 作为服务端,只提供 JSON 格式的 API 接口。
实际开发中,这个架构的选择有个很重要的原因:微信小程序的审核机制比较严格,如果业务逻辑全部写在小程序端,后期改一个字段就要重新提交审核。而把业务逻辑放在 Flask 后端,小程序端只是被动地渲染数据,很多改动只需要更新服务器,小程序端完全不用动。
我采用的是经典的三层结构:
小程序前端页面(WXML + WXSS + JS) ↓ HTTPS/JSON Flask API 层(路由 + 请求参数校验) ↓ 业务逻辑层(信息发布、线索匹配、状态流转) ↓ 数据访问层(SQLAlchemy ORM → SQLite/MySQL)这里有个实际的考量:小程序端不能直接访问数据库,也不能直接读取服务器文件,必须全部通过接口。所以后端 API 的设计需要覆盖所有业务场景,包括列表分页、详情查询、图片上传、线索提交、状态更新这些操作。
1.3 数据表结构设计:从需求反推模型
这一步是整个项目的地基。我一开始为了赶进度直接用了一个简单的child表,后来发现审核状态、线索上报、图片存多张这些需求全都需要返工改表。所以建议你动手写代码前,先把表结构设计清楚。
我这个项目最终设计了四张核心表:
第一张:走失儿童信息表(children)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | Integer | 主键自增 |
| name | String(50) | 走失儿童姓名 |
| gender | String(10) | 性别 |
| age | Integer | 走失时年龄 |
| height | Integer | 身高(cm) |
| missing_date | Date | 走失日期 |
| missing_location | String(255) | 走失地点(文字描述) |
| longitude | Float | 走失地点经度 |
| latitude | Float | 走失地点纬度 |
| features | Text | 体貌特征描述 |
| contact_name | String(50) | 联系人姓名 |
| contact_phone | String(20) | 联系人电话 |
| photo_urls | Text | 照片URL列表(JSON数组格式) |
| status | Integer | 状态:0待审核,1已发布,2已找到 |
| publisher_openid | String(100) | 发布者OpenID |
| create_time | DateTime | 创建时间 |
第二张:线索上报表(tips)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | Integer | 主键自增 |
| child_id | Integer | 关联的走失儿童ID |
| tip_content | Text | 线索内容 |
| tip_location | String(255) | 目击地点 |
| tip_time | DateTime | 目击时间 |
| reporter_openid | String(100) | 上报人OpenID |
| reporter_phone | String(20) | 上报人联系方式(可选) |
| status | Integer | 状态:0未处理,1处理中,2已确认 |
| create_time | DateTime | 创建时间 |
第三张:用户表(users)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | Integer | 主键自增 |
| openid | String(100) | 微信唯一标识 |
| nickname | String(50) | 昵称 |
| avatar_url | String(255) | 头像地址 |
| phone | String(20) | 手机号(可选) |
| user_type | Integer | 0普通用户,1管理员 |
| create_time | DateTime | 注册时间 |
第四张:审核日志表(audit_logs),记录管理员的审核操作,方便溯源。
这个表结构支撑起了完整的业务流程:用户登录小程序 → 发布寻亲信息(状态置为待审核)→ 管理员审核通过 → 信息在小程序首页展示 → 其他用户看到后上报线索 → 管理员确认线索 → 更新走失状态为已找到。
2. 核心功能模块拆解
2.1 寻亲信息发布与审核流程
寻亲信息发布不能是用户一发就能上架的,必须有审核环节。这不是增加用户操作成本,而是为了防止虚假信息和恶意内容被公开展示。毕竟寻亲信息是敏感内容,发错了会造成不必要的恐慌。
我这个项目里的审核流程是这样设计的:
- 用户在小程序端填写儿童信息表单,上传照片,提交后请求
/api/publish接口。 - Flask 后端接收到数据后,先做参数校验(姓名非空、走失日期合法、联系方式格式正确),然后封装成一个
children记录,status 设为 0,存入数据库。 - 管理员在小程序的管理页面或后台接口里看到待审核列表,点击通过后 status 置为 1。
- 小程序首页只查询 status=1 的记录。
这里有个细节:审核通过后,系统会自动给发布人的微信订阅消息推送一条审核结果通知。这个在微信小程序里需要开通订阅消息功能,并且在用户发布信息时主动询问授权。
我踩过的坑是:很多用户在小程序里点击发布之后,以为信息立刻就能被所有人看到,频繁地刷新首页发现自己发布的内容不出现,以为服务器挂了。后来我在发布成功的提示页上加了一句“您的信息已提交,审核通过后将公开展示”,这个问题就解决了。做公益项目,用户体验的细节往往比功能本身更重要。
2.2 搜索与模糊匹配:不只是 SQL 的LIKE那么简单
寻亲小程序最核心的检索场景是:用户输入一个名字或者地点,找到可能的匹配信息。最朴素的实现是 SQLite 的LIKE '%关键词%',比如:
children = Child.query.filter( Child.name.contains(keyword) | Child.missing_location.contains(keyword) ).all()但这里有几个问题。第一,SQLite 对中文的模糊匹配性能不够好,数据量稍微大一点就慢。第二,用户可能输错字,比如把“王馨”写成“王心”,LIKE就查不出来了。第三,搜索结果没有相关性排序。
我后来在项目里补了一个优化版本:在missing_location字段上额外存了一个location_keywords字段,把省、市、区、街道这些信息单独拆出来存,比如“广东省深圳市南山区科技园”会拆成广东省、深圳市、南山区、科技园四个关键词。查询时用多个关键词同时匹配,这个方案在数据量几万条以内表现都不错。
def search_children(keyword): if not keyword: return [] keyword = keyword.strip() # 先走精准匹配 results = Child.query.filter( and_( Child.status == 1, or_(Child.name == keyword, Child.missing_location.contains(keyword)), ) ).all() # 再走模糊匹配 if len(results) < 20: fuzzy_results = Child.query.filter( and_( Child.status == 1, or_( Child.name.like(f"%{keyword}%"), Child.missing_location.like(f"%{keyword}%"), Child.location_keywords.like(f"%{keyword}%"), ), ) ).limit(50).all() for item in fuzzy_results: if item not in results: results.append(item) return results提示:如果数据量过万,建议直接换 MySQL 并使用全文索引,或者引入 Elasticsearch。但真的做公益项目,前期不会有那么大的数据量,SQLite + LIKE 足够跑通。
另外一个增强搜索体验的小技巧:地区筛选。小程序首页放了一个地区下拉框,选择广东省后就只查missing_location包含“广东”的记录。这个功能在后端其实就是多一个filter_by条件:
children = Child.query.filter( Child.status == 1, Child.missing_location.contains(province) ).order_by(Child.create_time.desc()).all()2.3 附近寻亲:LBS 地理围栏的实现
很多人会忽略一个点:寻亲信息最好能按地理位置推荐给附近的人。比如你在深圳看到一个走失儿童信息,如果这个孩子是在深圳走失的,那么深圳本地用户刷到的优先级应该更高。
这个在小程序端取用户定位的经纬度,然后传给 Flask 后端。后端根据经纬度计算距离,按距离排序返回结果。我用的是 Haversine 公式计算两个经纬度点之间的距离:
import math def haversine_distance(lat1, lng1, lat2, lng2): # 将经纬度转换为弧度 lat1, lng1, lat2, lng2 = map(math.radians, [lat1, lng1, lat2, lng2]) dlat = lat2 - lat1 dlng = lng2 - lng1 a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlng / 2) ** 2 c = 2 * math.asin(math.sqrt(a)) r = 6371 # 地球平均半径,单位公里 return c * r然后按距离过滤出 50 公里范围内的信息,再叠加发布时间排序。这个功能做好之后,整个小程序的“就近寻亲”感觉就出来了,而且面试讲项目的时候这个故事也讲得通。
不过要提醒一个问题:小程序端获取用户定位需要用户授权。如果用户拒绝授权,后端就收不到经纬度,这时程序要能兜底,默认按发布时间倒序,不能报错。
@app.route('/api/nearby', methods=['POST']) def nearby_children(): data = request.get_json() lat = data.get('latitude') lng = data.get('longitude') children = Child.query.filter_by(status=1).all() if lat is None or lng is None: children.sort(key=lambda x: x.create_time, reverse=True) return jsonify({'code': 0, 'data': [child.to_dict() for child in children[:20]]}) # 带距离排序 for child in children: if child.latitude is not None and child.longitude is not None: child.distance = haversine_distance(lat, lng, child.latitude, child.longitude) else: child.distance = float('inf') children.sort(key=lambda x: (x.distance, -x.create_time.timestamp())) result = [child.to_dict() for child in children[:50]] return jsonify({'code': 0, 'data': result})2.4 线索上报与通知机制
线索上报是整个寻亲流程里的关键环节。一个用户看到某条走失信息,觉得在某个地方见过这个孩子,就可以点“我要提供线索”按钮,填写目击地点、目击时间、线索描述,甚至上传一张现场照片。
这里涉及的逻辑比普通的上报功能复杂一些:上报的线索不能直接在儿童信息页里显示给所有人看(保护上报人隐私),只能给管理员或者走失信息发布者查看。所以我给线索表设计了一个status字段:0 表示未处理,1 表示处理中,2 表示已确认。管理员在后台看到新线索后,可以联系上报人核实情况。
通知机制我用了两种方式:
第一是站内消息。线索上报成功后,在走失信息发布人的用户中心里生成一条站内消息,内容是“有用户在xxx地点提供了一条线索”。
第二是微信订阅消息。这个实现稍微绕一点,需要在发布信息时让用户点击一个按钮授权订阅消息模板,后端存下用户的formId,当线索来的时候才能给发布人发送通知。微信对订阅消息的次数有限制,不能滥用。
代码层面,发送订阅消息需要调用微信的 API,核心代码如下:
import requests import json def send_subscribe_message(openid, template_id, page, data_dict): # 获取 access_token token_url = f"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={APP_ID}&secret={APP_SECRET}" resp = requests.get(token_url).json() access_token = resp.get('access_token') if not access_token: return False # 发送订阅消息 send_url = f"https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token={access_token}" payload = { "touser": openid, "template_id": template_id, "page": page, "data": data_dict, "miniprogram_state": "formal" } resp = requests.post(send_url, json=payload) result = resp.json() return result.get('errcode') == 0我踩过的一个坑:微信小程序的订阅消息是一次性的。你授权一次,只能发一条。所以不能指望像公众号模板消息那样无限发。实际项目里只能挑最关键的节点推送,比如“审核通过”和“线索确认”这两个节点。
3. 实操过程与核心环节实现
3.1 Flask 后端项目结构和环境搭建
先看项目结构。我习惯按功能模块拆分,而不是把所有路由写在一个app.py里。尤其是这种信息管理类项目,路由一多之后单文件根本维护不了。
flask-xunqin/ ├── app.py # 应用入口 ├── config.py # 配置文件 ├── models/ │ ├── __init__.py │ ├── child.py # 儿童信息模型 │ ├── tip.py # 线索模型 │ └── user.py # 用户模型 ├── api/ │ ├── __init__.py │ ├── auth.py # 登录认证接口 │ ├── child_api.py # 儿童信息接口 │ ├── tip_api.py # 线索接口 │ └── upload.py # 图片上传接口 ├── utils/ │ ├── __init__.py │ ├── geo.py # 地理计算工具 │ └── response.py # 统一返回格式 ├── static/uploads/ # 上传图片目录 ├── requirements.txt └── run.py # 启动脚本环境搭建不复杂,Python 3.8+ 就行,依赖主要就是 Flask、Flask-SQLAlchemy、Flask-CORS、requests、Pillow(处理图片)。requirements.txt内容如下:
Flask==2.3.3 Flask-Cors==4.0.0 Flask-SQLAlchemy==3.1.1 requests==2.31.0 Pillow==10.1.0创建虚拟环境并安装:
python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install -r requirements.txt3.2 应用工厂模式与数据库初始化
项目入口run.py里,我用的是 Flask 应用工厂模式。这种模式的好处是方便不同环境使用不同的配置,也方便写单元测试。
from flask import Flask from flask_sqlalchemy import SQLAlchemy from flask_cors import CORS db = SQLAlchemy() def create_app(config_class=None): app = Flask(__name__) app.config.from_object('config.Config') db.init_app(app) CORS(app) # 注册蓝图 from api.auth import auth_bp from api.child_api import child_bp from api.tip_api import tip_bp from api.upload import upload_bp app.register_blueprint(auth_bp, url_prefix='/api') app.register_blueprint(child_bp, url_prefix='/api') app.register_blueprint(tip_bp, url_prefix='/api') app.register_blueprint(upload_bp, url_prefix='/api') with app.app_context(): db.create_all() return app app = create_app() if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True)config.py里配置数据库地址、密钥和上传目录:
import os class Config: SECRET_KEY = 'your-secret-key-here' SQLALCHEMY_DATABASE_URI = 'sqlite:///xunqin.db' SQLALCHEMY_TRACK_MODIFICATIONS = False UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), 'static/uploads') MAX_CONTENT_LENGTH = 10 * 1024 * 1024 # 限制上传文件大小 10MB提示:开发环境用 SQLite 足够,部署到线上建议切 MySQL,把
SQLALCHEMY_DATABASE_URI换成mysql+pymysql://用户名:密码@地址:3306/数据库名就行。
3.3 数据模型定义
这里以儿童信息模型为例,看看 SQLAlchemy 的模型怎么定义。注意我把photo_urls设计为 Text 字段,存 JSON 数组字符串,这样能支持一个儿童上传多张照片,查询时再解析成列表。
from datetime import datetime from app import db import json class Child(db.Model): __tablename__ = 'children' id = db.Column(db.Integer, primary_key=True, autoincrement=True) name = db.Column(db.String(50), nullable=False) gender = db.Column(db.String(10), nullable=False) age = db.Column(db.Integer, default=0) height = db.Column(db.Integer, nullable=True) missing_date = db.Column(db.Date, nullable=False) missing_location = db.Column(db.String(255), nullable=False) location_keywords = db.Column(db.String(255), default='') longitude = db.Column(db.Float, nullable=True) latitude = db.Column(db.Float, nullable=True) features = db.Column(db.Text, nullable=True) contact_name = db.Column(db.String(50), nullable=False) contact_phone = db.Column(db.String(20), nullable=False) photo_urls = db.Column(db.Text, default='[]') status = db.Column(db.Integer, default=0) publisher_openid = db.Column(db.String(100), nullable=False) create_time = db.Column(db.DateTime, default=datetime.now) def to_dict(self): return { 'id': self.id, 'name': self.name, 'gender': self.gender, 'age': self.age, 'height': self.height, 'missing_date': self.missing_date.strftime('%Y-%m-%d') if self.missing_date else '', 'missing_location': self.missing_location, 'features': self.features, 'contact_name': self.contact_name, 'contact_phone': self.contact_phone, 'photo_urls': json.loads(self.photo_urls or '[]'), 'status': self.status, 'create_time': self.create_time.strftime('%Y-%m-%d %H:%M:%S') }to_dict()方法很关键。直接返回 ORM 对象给前端会有序列化问题,转成普通字典后就能用jsonify直接输出了。
3.4 API 接口设计与实现
后端接口是核心,这里把关键接口都列出来,方便你对照实现。
登录接口
小程序的登录机制是用wx.login获取一个临时 code,传给后端,后端拿 code 到微信服务器换取 openid。拿到 openid 后,查数据库有没有这个用户,没有就自动注册一个,然后返回一个自定义 token 给小程序后续请求使用。
@auth_bp.route('/login', methods=['POST']) def login(): data = request.get_json() code = data.get('code') nickname = data.get('nickname', '') avatar = data.get('avatar_url', '') # 用 code 换取 openid url = f"https://api.weixin.qq.com/sns/jscode2session?appid={APP_ID}&secret={APP_SECRET}&js_code={code}&grant_type=authorization_code" resp = requests.get(url) wx_data = resp.json() openid = wx_data.get('openid') if not openid: return jsonify({'code': 1, 'msg': '登录失败'}) user = User.query.filter_by(openid=openid).first() if not user: user = User(openid=openid, nickname=nickname, avatar_url=avatar, user_type=0) db.session.add(user) db.session.commit() return jsonify({'code': 0, 'data': {'token': user.id, 'user_id': user.id, 'nickname': user.nickname}})注意:我这里图省事直接用
user.id当 token 了。正规项目建议用itsdangerous或PyJWT生成带过期时间的签名 token。因为走失儿童信息涉及到联系方式等隐私,Token 机制必须要有,否则任何人都能通过接口拿到所有用户的手机号。
发布寻亲信息接口
@child_bp.route('/publish', methods=['POST']) def publish_child(): data = request.get_json() # 基础校验 required_fields = ['name', 'gender', 'missing_date', 'missing_location', 'contact_phone', 'publisher_openid'] for field in required_fields: if not data.get(field): return jsonify({'code': 1, 'msg': f'缺少必填字段:{field}'}) child = Child( name=data['name'], gender=data['gender'], age=data.get('age', 0), height=data.get('height'), missing_date=datetime.strptime(data['missing_date'], '%Y-%m-%d'), missing_location=data['missing_location'], location_keywords=data.get('location_keywords', ''), longitude=data.get('longitude'), latitude=data.get('latitude'), features=data.get('features', ''), contact_name=data.get('contact_name', data['name']), contact_phone=data['contact_phone'], photo_urls=json.dumps(data.get('photo_urls', [])), status=0, # 待审核 publisher_openid=data['publisher_openid'] ) db.session.add(child) db.session.commit() return jsonify({'code': 0, 'data': child.to_dict(), 'msg': '发布成功,等待审核'})信息列表接口(分页 + 筛选)
首页的信息流我用的是分页加载,每页 10 条,下拉到底部自动加载下一页。这个接口直接决定小程序端的滚动体验。
@child_bp.route('/children', methods=['GET']) def get_children(): page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 10, type=int) status = request.args.get('status', 1, type=int) query = Child.query.filter_by(status=status) total = query.count() pagination = query.order_by(Child.create_time.desc()).paginate(page=page, per_page=per_page, error_out=False) data = { 'total': total, 'page': page, 'per_page': per_page, 'items': [item.to_dict() for item in pagination.items], 'has_more': pagination.has_next } return jsonify({'code': 0, 'data': data})线索上报接口
@tip_bp.route('/report', methods=['POST']) def report_tip(): data = request.get_json() child_id = data.get('child_id') content = data.get('content') location = data.get('location', '') tip_time = data.get('tip_time') reporter_openid = data.get('reporter_openid') reporter_phone = data.get('reporter_phone', '') if not child_id or not content: return jsonify({'code': 1, 'msg': '线索内容和关联信息为必填项'}) tip = Tip( child_id=child_id, tip_content=content, tip_location=location, tip_time=datetime.strptime(tip_time, '%Y-%m-%d %H:%M') if tip_time else datetime.now(), reporter_openid=reporter_openid, reporter_phone=reporter_phone, status=0 ) db.session.add(tip) db.session.commit() return jsonify({'code': 0, 'msg': '线索上报成功,我们会尽快核实'})3.5 小程序端页面实现
小程序端我用的是原生微信小程序开发,没有用 uni-app 或者 Taro。原因是我这个项目里涉及wx.chooseImage、wx.getLocation、wx.login这些原生 APIs,原生开发调试最省事,不用处理多端编译的兼容问题。
核心页面有四个:首页(资讯流)、搜索页、发布页、我的页面,外加一个详情页。
首页的index.js里,核心逻辑是请求后端接口,渲染信息流列表:
Page({ data: { childrenList: [], page: 1, hasMore: true, loading: false }, onLoad() { this.loadChildren() }, loadChildren() { if (this.data.loading || !this.data.hasMore) return this.setData({ loading: true }) wx.request({ url: 'https://你的域名/api/children', data: { page: this.data.page, per_page: 10 }, success: (res) => { const { items, has_more, page } = res.data.data this.setData({ childrenList: this.data.childrenList.concat(items), hasMore: has_more, page: page + 1 }) }, complete: () => { this.setData({ loading: false }) } }) }, onReachBottom() { this.loadChildren() } })详情页的detail.js比较有代表性,它需要根据id拉取单条信息详情,同时展示图片轮播、联系人和“提供线索”按钮:
Page({ data: { childId: null, child: null, showTipModal: false, tipContent: '', tipLocation: '' }, onLoad(options) { this.setData({ childId: options.id }) this.fetchDetail() }, fetchDetail() { wx.request({ url: `https://你的域名/api/children/${this.data.childId}`, success: (res) => { this.setData({ child: res.data.data }) } }) }, submitTip() { const userOpenid = wx.getStorageSync('openid') wx.request({ url: 'https://你的域名/api/tip/report', method: 'POST', data: { child_id: this.data.childId, content: this.data.tipContent, location: this.data.tipLocation, reporter_openid: userOpenid }, success: (res) => { if (res.data.code === 0) { wx.showToast({ title: '上报成功', icon: 'success' }) this.setData({ showTipModal: false }) } } }) } })发布页的表单比较繁琐,但逻辑不复杂。要注意的是图片上传,不能直接传 base64 给后端,因为小程序端图片压缩和传输效率都很差。正确做法是先调用wx.uploadFile把图片传到 Flask 的/api/upload接口,拿到返回的图片 URL 后,再把 URL 拼到表单数据里一起提交发布接口。
wx.chooseImage({ count: 3, success: (res) => { const uploadTasks = res.tempFilePaths.map((filePath) => { return new Promise((resolve) => { wx.uploadFile({ url: 'https://你的域名/api/upload', filePath: filePath, name: 'file', success: (uploadRes) => { const data = JSON.parse(uploadRes.data) resolve(data.data.url) } }) }) }) Promise.all(uploadTasks).then((urls) => { this.setData({ photoUrls: this.data.photoUrls.concat(urls) }) }) } })3.6 图片上传接口与静态资源处理
Flask 后端的图片上传接口是踩坑重灾区。核心流程是:接收文件 → 校验类型和大小 → 重命名文件 → 保存到指定目录 → 返回可访问的 URL。
import os import uuid from flask import request, jsonify from werkzeug.utils import secure_filename from PIL import Image ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS @upload_bp.route('/upload', methods=['POST']) def upload_image(): file = request.files.get('file') if not file: return jsonify({'code': 1, 'msg': '未接收到文件'}) if not allowed_file(file.filename): return jsonify({'code': 1, 'msg': '文件格式不支持'}) # 用 uuid 重命名,避免文件名冲突 ext = file.filename.rsplit('.', 1)[1].lower() filename = f"{uuid.uuid4().hex}.{ext}" filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename) # 用 Pillow 压缩图片,限制最大尺寸 img = Image.open(file) img.thumbnail((800, 800)) img.save(filepath, quality=85) # 生成 URL 返回 url = f"/static/uploads/{filename}" return jsonify({'code': 0, 'data': {'url': url}})注意:上传接口必须加文件类型校验,否则容易被上传恶意文件。我这边用 Pillow 重新编码图片,一方面能压缩体积,另一方面也避免了一些恶意内容混入。
部署时这个路径问题坑了我很久。本地开发时UPLOAD_FOLDER用绝对路径没问题,但部署到服务器后,如果直接用os.path.join(os.path.dirname(__file__), 'static/uploads'),会遇到 Nginx 静态文件路径和 Flask 访问路径不一致的问题。我最终的解决方案是上传时同时记录文件名,访问时通过 Flask 的send_from_directory提供:
@upload_bp.route('/static/uploads/<filename>') def get_upload(filename): return send_from_directory(current_app.config['UPLOAD_FOLDER'], filename)4. 常见问题与排查技巧实录
4.1 小程序请求后端接口必须用 HTTPS
微信小程序不是普通的网页,它强制要求所有请求必须走 HTTPS,而且在微信公众平台后台必须配置域名白名单。我第一次调试的时候把 Flask 跑在本地http://127.0.0.1:5000,微信开发者工具里直接报了request:fail。
解决办法有两个:
开发阶段,在微信开发者工具的“详情 → 本地设置”里勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”。这个选项能让开发者工具跳过域名校验,方便本地联调。
真机测试和上线阶段,必须准备一个 HTTPS 域名。我用的是阿里云服务器 + 宝塔面板,申请免费 SSL 证书,配置 Nginx 反向代理到 Flask 的 5000 端口。Nginx 配置大致如下:
server { listen 443 ssl; server_name yourdomain.com; ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem; location / { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /static/ { alias /path/to/flask-xunqin/static/; } }4.2 Flask 跨域问题
小程序端请求 Flask 接口时,如果 Flask 端没有配置 CORS,会在控制台看到Access-Control-Allow-Origin相关的报错。虽然小程序不是浏览器,理论上没有同源策略限制,但如果在微信开发者工具里调试,某些场景下还是会触发跨域校验。
解决办法是在 Flask 里加上 Flask-CORS:
from flask_cors import CORS CORS(app, resources={r"/api/*": {"origins": "*"}})注意:生产环境把
origins从*换成你自己的小程序域名,防止别人直接调用你的接口。
4.3 图片上传大小限制
Flask 的MAX_CONTENT_LENGTH默认是无限大的,但不设置的话,用户上传一个几十 MB 的图片会导致服务器卡死。我设置了 10MB 的上限,配合 Pillow 压缩,一张照片最终存储体积基本在 100KB 左右。
如果用户上传的图片超过限制,Flask 会抛 413 异常。我加了一个全局异常处理器,把错误转成 JSON 格式返回:
@app.errorhandler(413) def too_large(e): return jsonify({'code': 1, 'msg': '图片大小超出限制(最大10MB)'}), 4134.4 数据库字段为空导致接口报错
这个坑出现频率极高。比如用户发布信息时不填身高,height字段存的是None。前端拿到None后在if判断里不会报错,但在wx:if="{{item.height}}"渲染时表现为空白,看起来没事。但如果你在to_dict()里直接对None调用方法,比如self.missing_date.strftime('%Y-%m-%d'),就一定会爆AttributeError。
我的经验是:to_dict()方法里每个字段都要做 None 判断。上面模型定义代码里我已经加了这个逻辑,这是血的教训换来的。
4.5 搜索接口慢的排查
上线一段时间后,发现搜索接口偶尔会慢到 2 秒以上。排查过程分三步:
第一步,看是不是数据量大。我去数据库看children表,总共不到 3000 条记录,完全不应该这么慢。
第二步,加日志看 SQL 执行时间。在搜索函数里加print和time.time(),发现瓶颈在LIKE '%关键词%',尤其是location_keywords字段没有索引,全表扫描。
第三步,临时方案:给location_keywords加了普通索引,搜索速度明显提升。根本方案:数据量到 5 万条以上后,换 MySQL 并引入全文索引。
提示:SQLite 的
LIKE对中文不友好,如果搜索的是拼音关键字,效果很差。一个可行的优化是加一个pinyin字段,存中文对应的拼音首字母,比如“深圳”存成“sz”,搜索时直接匹配pinyin字段。
4.6 用户身份识别与数据安全
微信号和手机号是小程序项目里最敏感的数据。我有一个经验:后端接口里不要返回用户的完整微信号和手机号,除非是信息发布者和线索上报者之间需要联系。
具体做法:儿童信息详情接口里,如果请求者不是发布者本人,contact_phone做脱敏处理,只显示前三位和后四位,比如“138****5678”。用户如果想获取完整联系方式,可以通过小程序内的“联系发布者”按钮,后端记录这个行为,并发送一条通知给发布者,由发布者决定是否主动联系。
代码实现不复杂,就是加个判断:
def to_dict(self, is_owner=False): data = { ... } if not is_owner and data.get('contact_phone'): phone = data['contact_phone'] data['contact_phone'] = phone[:3] + '****' + phone[-4:] return data4.7 部署后静态文件 404
这个问题很典型。本地开发时一切正常,部署到服务器后图片全部 404。原因通常是 Nginx 配置的静态文件路径不对。
检查路径的核心是理解两个“路径”的区别:alias指定的是磁盘上的实际路径,location /static/指定的是 URL 路径。两者必须正确对应。我在 Nginx 配置里加了一段日志,排查时直接看/var/log/nginx/error.log,里面会明确写出请求的静态文件路径和实际查找路径。
最后的经验是:部署完后先不要急着在小程序里看效果,直接用浏览器访问https://你的域名/api/children接口,再访问图片 URL,确认接口和静态资源都通了再进小程序调试,能节省大量时间。
5. 项目延展与后续优化方向
如果你想把项目做得更完整,还有几个方向值得投入:
第一,增加管理员审核后台。目前审核是通过小程序的管理员页面做的,其实可以做一个独立的 Flask 网页后台,用简单的用户名密码登录,管理待审核信息、处理线索列表、修改走失状态。Flask 的模板渲染或者 Flask-Admin 插件都能实现,工作量不大但很实用。
第二,接入人脸比对算法。走失儿童找回的一个核心痛点是“儿童长大后长相变化大”。可以接入免费的人脸识别服务,让用户上传走失时的照片和疑似现在的照片,用算法计算相似度。这个功能做出来,项目的技术含金量会提升一个档次。
第三,信息定时提醒。利用 Flask 的定时任务(比如 APScheduler),每周给用户推送一次“本周新增走失儿童信息”的订阅消息,提高信息的曝光率。
第四,数据统计可视化。用 Flask 提供统计接口,统计各地区的走失数量、找回比例、走高发年龄段等,做成管理后台的图表。公众人员也能看到这些数据,增加透明度和公信力。
这些方向我在实际项目中验证过,不需要在初期全部做。先把基础的信息发布、搜索、线索上报跑通,再根据使用反馈迭代,才是正确的节奏。
写在最后
这个项目前前后后我调试了三个星期,最深的体会是:做公益类小程序,技术难点其实不在代码本身,而在于信息可靠性和用户体验的平衡。一方面要防止虚假信息,所以要有审核;一方面又不能让用户觉得操作麻烦,所以发布流程要简洁。我在发布页上反复改了四五版,最终确定了一个两页的表单设计,第一页填基本信息,第二页传照片和联系方式,既保证了信息完整度,又不会让用户觉得表单太长。
如果你也准备做类似的小程序,建议先跑通最核心的一条链路:发布信息 → 审核通过 → 列表展示 → 线索上报 → 状态更新。这条链路通了,剩下的都是加功能的工作。这个项目代码量不大,适合作为你 Flask 和小程序学习路上的一个里程碑,而且它面向的是真实的社会需求,这个出发点本身就很有价值。