
前两天终于把这套房屋租赁管理系统的后端和小程序端全部收尾趁记忆还热乎把完整思路和踩坑过程记录一下。这套系统用Python写了后端微信小程序做了前端业务上覆盖两大块一块是房屋租赁里的故障报修另一块是应收应付管理。后端框架用了Flask和Django两个框架同时出现不是故意炫技而是项目演进过程中自然形成的搭配后面我会交代具体原因。如果你正在给公寓、二房东、物业公司做信息化系统或者正在学Python后端的Web开发这篇内容应该能给你不少可以照抄的模块和方案。1. 项目背景公寓运营为什么离不开数字化报修与对账1.1 先从几个痛点说起我之前帮一家小型租赁公司做过一套内部系统。他们的业务模式很简单整租了几栋楼再分成几十间房出租出去租客多是刚毕业的上班族。以前租客要报修只能在微信群里喊或者打电话给管家管家再用笔记本记下来联系维修师傅上门。听起来没什么真跑起来全是坑。租客说“空调不制冷”维修师傅到了说“得找厂家”管家再转一道电话修完多少钱、材料谁出、房东和租客各自承担多少全凭群里聊天记录。月末一算账管家拿来几张手写单据和微信转账截图财务对着租金表一遍遍核一天能核出三四个对不上的地方。房租催缴更是常规痛点逾期一两周没人提醒月底才突然想起来该收钱了。这些看似不起眼的小事放到几十上百间房里就变成每天都要处理的噪音。报修工单、应收账单、支付记录、维修费用如果没有一个统一的地方沉淀下来规模越大管理成本越高。所以做这套系统的核心目的不只是把流程搬到线上而是让每一笔“事情”和每一笔“钱”都留下可追溯的数据。1.2 这套系统到底做了什么我要做的是一套同时能跑在微信里和后台里的工具。租客端是一个微信小程序可以提交报修、查工单进度、看账单、在线缴费运营端是一个Python后端维护房源、合同、账单和工单流转。我把项目分成两个模块第一个是房屋租赁故障报修系统第二个是应收应付管理系统。从代码层面看它们共享同一套数据库但在业务层面一个负责“事”一个负责“钱”。报修系统的核心是工单。租客提交报修单管理员接单后指派维修师傅师傅处理完填写费用和材料租客确认验收工单关闭。确认验收的时候系统会根据费用类型自动生成一笔应收款或应付款。应收款是租客要承担的维修费应付款是公司付给维修师傅的人工费或材料供应商的货款它们都会进入应收应付管理模块。应收应付系统的核心是账单和流水。应收侧管租金、押金、水电网费和租客承担的维修费应付侧管维修人工、耗材采购、退款押金。每一笔收入或支出都对应一张可追溯的账单或凭证这样到了月底不光能算出“这个月收了多少钱”还能算出“哪些钱没收上来”、“修房子贴了多少钱”。1.3 适合谁参考如果你是Python开发者正在找Web项目练手这套系统的后端技术栈不算高却把用户登录、权限、状态流转、账单对账、定时任务这些常见Web开发能力都覆盖了。如果你是物业系统外包项目的实施人员这套业务模型可以直接复用到公寓、写字楼甚至园区场景里。我下面写的都是实际跑通过的做法可以当成一份带注释的参考文档用。2. 技术选型为什么一套系统里同时出现了Flask和Django2.1 一开始只用Flask可能更顺手项目启动的时候我只想赶紧把报修流程跑通让租客不用再在群里喊话。Flask的好处是轻一个文件就能把接口怼出来路由、请求参数、返回JSON都很好理解。对于小程序后端来说绝大多数接口就是接收POST、查数据库、返回状态Flask完全够用。我当时在虚拟环境里装好Flask、SQLAlchemy和PyMySQL花了半天就写完了“创建报修单”和“查询列表”两个接口。开发阶段用Flask内置服务器跑前端小程序填上内网IP关掉域名校验就能联调。效率很高尤其适合一个人迅速验证业务想法。但项目继续往后推问题来了。报修单需要管理员后台要区分角色权限要维护楼栋、房间、合同后来还要接账单和支付回调。如果这些都用Flask手写工作量会指数增加。Django自带的Admin后台、ORM、迁移工具、认证体系能省掉很多基础工作。于是我在第一版跑通后把后台管理和财务部分迁到了Django报修API继续留在Flask侧。2.2 Flask和Django分工协作的架构最后形成的架构是Flask服务专门提供小程序端调用的报修类API包括创建工单、查询工单、上传图片、状态变更通知。Django服务负责房屋、租客、合同、账单、付款记录的管理提供Django Admin给运营人员使用同时暴露一部分统计和账单API给小程序的账单页。共用同一个MySQL数据库。Flask通过SQLAlchemy读取同一套表Django通过自己的ORM管理同一套表两边通过业务字段关联。Redis用来缓存登录态和工单状态避免频繁读写数据库。严格来说一个项目里同时维护两个Web框架会增加部署复杂度但并没有想象中那么吓人只要定义好边界谁负责哪些表、哪些接口是稳定的两个服务就能并行开发部署。如果你是一个人开发我更推荐这种做法把快速迭代的部分放在Flask里把需要管理的部分放在Django里风险会小很多。当然如果一开始团队能力足够直接全用Django也没有问题完全取决于项目节奏和人员情况。有人会觉得Django和Flask混用很别扭但实际用下来真正让你头疼的往往不是框架本身而是跨服务的接口约定和数据库字段约定。把公共的表结构定义清楚用一套命名规范管理两个框架其实可以相安无事。2.3 开发环境的细节我用的Python 3.10装依赖的时候建议用虚拟环境。无论你用Flask还是Django先建venv再装依赖避免系统Python环境被弄乱。用VSCode开发时记得把解释器指到venv的Python否则运行时会提示找不到flask模块。数据库我选了MySQL 8.0字符集utf8mb4因为报修描述和账单备注会出现中文和emoji。驱动建议用PyMySQLDjango里配置好pymysql后要把install_as_MySQLdb()写进__init__.py不然会报No module named MySQLdb。MySQL 5.7也能跑但8.0对JSON字段和窗口函数的支持更好统计报表写起来省心很多。3. 业务设计与数据模型让报修工单和账单挂上钩3.1 从报修到对账的完整链路在设计数据库之前先把业务闭环画出来。租客登录小程序选择所在房源和房间填故障类型和描述可拍照上传提交后生成待接单工单。管理员在后台看到新工单手动指派给维修师傅师傅接单后状态变为维修中完成后填写维修结果、人工费和材料费。租客确认后工单关闭同时系统创建一笔费用账单。费用账单这里有两种走向如果维修责任在租客比如人为损坏账单记到租客名下进入应收如果责任在公司或房东比如自然老化这笔费用记到公司名下进入应付之后付给维修师傅或材料商。每个月末系统按房源维度汇总应收、实收、欠收以及应付、已付、未付就是最基础的对账报表。这段链路看着简单但每走一步都在产生业务数据。报修单关联房子、租客、维修师傅、金额账单关联合同、报修单、收款记录。最后所有数据汇总到一张对账表财务才敢对数字负责。3.2 数据模型长什么样我用SQLAlchemy风格把核心表写出来。字段没有刻意精简都是实际用到的这样理解起来更直接class Building(db.Model): __tablename__ building id db.Column(db.Integer, primary_keyTrue) name db.Column(db.String(100), nullableFalse) address db.Column(db.String(255)) rooms db.relationship(Room, backrefbuilding) class Room(db.Model): __tablename__ room id db.Column(db.Integer, primary_keyTrue) building_id db.Column(db.Integer, db.ForeignKey(building.id)) room_no db.Column(db.String(20), uniqueTrue) rent_amount db.Column(db.Numeric(10, 2), default0) status db.Column(db.String(20), defaultempty) # empty / rented / repair class Tenant(db.Model): __tablename__ tenant id db.Column(db.Integer, primary_keyTrue) name db.Column(db.String(50)) phone db.Column(db.String(20)) id_card db.Column(db.String(30)) class Lease(db.Model): __tablename__ lease id db.Column(db.Integer, primary_keyTrue) tenant_id db.Column(db.Integer, db.ForeignKey(tenant.id)) room_id db.Column(db.Integer, db.ForeignKey(room.id)) start_date db.Column(db.Date) end_date db.Column(db.Date) monthly_rent db.Column(db.Numeric(10, 2)) deposit db.Column(db.Numeric(10, 2)) class RepairTicket(db.Model): __tablename__ repair_ticket id db.Column(db.Integer, primary_keyTrue) lease_id db.Column(db.Integer, db.ForeignKey(lease.id)) category db.Column(db.String(50)) # 如空调、水电、门窗 description db.Column(db.Text) images db.Column(db.JSON) # 图片URL列表 status db.Column(db.String(20), defaultpending) assignee_name db.Column(db.String(50)) applied_at db.Column(db.DateTime, defaultdatetime.now) completed_at db.Column(db.DateTime) labor_fee db.Column(db.Numeric(10, 2), default0) material_fee db.Column(db.Numeric(10, 2), default0) responsibility db.Column(db.String(10)) # tenant / company账单部分单独拆了一张表用bill_type区分钱的名目class Bill(db.Model): __tablename__ bill id db.Column(db.Integer, primary_keyTrue) lease_id db.Column(db.Integer, db.ForeignKey(lease.id)) ticket_id db.Column(db.Integer, nullableTrue) # 如果是从报修生成的费用记录工单ID bill_type db.Column(db.String(20)) # rent / deposit / utility / repair / refund direction db.Column(db.String(10)) # receivable / payable title db.Column(db.String(100)) amount db.Column(db.Numeric(10, 2)) due_date db.Column(db.Date) status db.Column(db.String(20), defaultunpaid) # unpaid / paid / canceled paid_at db.Column(db.DateTime)这张Bill表是整个应收应付系统的主心骨。所有应收款和应付款都落到这一张表里只是用direction区分方向再配合bill_type知道钱的名目。这样统计报表只需要对Bill做GROUP BY不用在多张表之间做UNION。3.3 为什么要反复强调关联关系我在第一版里把维修费用直接写进RepairTicket没有单独生成Bill当时觉得省事。后来发现月底对账时非常痛苦工单里有一个费用账单里没有财务只能人工再去翻。后来改成在工单关闭时自动创建Bill并且把ticket_id存进Bill表问题才彻底解决。这就是业务上最重要的“闭环”报修工单和费用账单必须互相可查。从工单能查到生成的账单从账单能倒查到关联工单。如果开发时没把这条链路建起来后面每一张报表都要拼数据越做越难维护。后来我还加了一个约束同一张工单最多只能生成一笔待支付的维修费用账单避免重复计费。4. 核心代码实现Flask、Django和小程序端的对接细节4.1 报修工单状态机工单状态不能散落在各个接口的判断里我封装成一个常量类然后在状态变更前统一校验class TicketStatus: PENDING pending # 待接单 ASSIGNED assigned # 已指派 IN_PROGRESS progress # 维修中 COMPLETED completed # 待验收 CLOSED closed # 已关闭 CANCELED canceled # 已取消每次状态变更用一个装饰器或者中间层检查ALLOWED_TRANSITIONS { TicketStatus.PENDING: [TicketStatus.ASSIGNED, TicketStatus.CANCELED], TicketStatus.ASSIGNED: [TicketStatus.IN_PROGRESS, TicketStatus.PENDING], TicketStatus.IN_PROGRESS: [TicketStatus.COMPLETED], TicketStatus.COMPLETED: [TicketStatus.CLOSED], }只有在这里登记过的状态跳转才允许执行否则直接返回错误。这个小设计帮我避开了很多“工单突然从维修中变成已取消”的乌龙。状态机会让代码更清晰后期加“重新打开工单”这类功能时也只要在字典里加一条映射。4.2 Flask侧报修接口Flask侧主要给小程序提供JSON API下面是一个创建报修单的简洁版本app.post(/api/repair/tickets) def create_ticket(): data request.get_json() token request.headers.get(X-Token) tenant auth_service.verify_token(token) if not tenant: return jsonify(code401, msg登录已过期), 401 ticket RepairTicket( lease_idtenant.current_lease_id, categorydata.get(category), descriptiondata.get(description), imagesdata.get(images, []), statusTicketStatus.PENDING ) db.session.add(ticket) db.session.commit() return jsonify(code0, data{ticket_id: ticket.id})这里有几个容易踩坑的地方。一是不要直接使用request.form小程序端如果设置header[content-type] application/json后端拿到的就是JSON体必须用request.get_json()。二是token校验必须在进入业务逻辑前完成不能等建完单再校验否则会写入脏数据。三是图片不要和表单一起提交而是先调上传接口拿到URL再提交表单速度更快也方便失败重试。查询工单时按租客维度查只返回自己名下的记录app.get(/api/repair/tickets) def list_tickets(): tenant auth_service.verify_token(request.headers.get(X-Token)) tickets RepairTicket.query.filter_by(lease_idtenant.current_lease_id) \ .order_by(RepairTicket.applied_at.desc()).all() return jsonify(code0, data[ticket.to_dict() for ticket in tickets])4.3 Django侧账单和统计接口Django这边我用的是Django REST Framework。账单列表接口很简单class BillViewSet(viewsets.ModelViewSet): queryset Bill.objects.select_related(lease__room).all() serializer_class BillSerializer filterset_fields [lease_id, direction, bill_type, status]需要注意Django默认权限比较严格小程序端调用这些接口时不要用Session认证换成TokenAuthentication更合适。在settings.py里设置REST_FRAMEWORK { DEFAULT_AUTHENTICATION_CLASSES: [ rest_framework.authentication.TokenAuthentication, ], }Django的QuerySet有个容易被新手忽略的点删除对象时Bill.objects.get(id1).delete()会直接删掉整行。如果账单已经支付过就不该删除而应该改状态为canceled。业务上“作废”和“删除”是两个动作数据库里尽量不要物理删除有业务含义的数据。这一点在开发里非常容易忽略。再看一个对账统计的写法。要统计某个月所有应收和应付的汇总可以用Django的聚合函数from django.db.models import Sum def monthly_report(year, month): bills Bill.objects.filter(due_date__yearyear, due_date__monthmonth) receivable bills.filter(directionreceivable).aggregate( totalSum(amount), paidSum(amount, filterQ(statuspaid)) ) payable bills.filter(directionpayable).aggregate( totalSum(amount), paidSum(amount, filterQ(statuspaid)) ) return { receivable_total: receivable[total] or 0, receivable_paid: receivable[paid] or 0, payable_total: payable[total] or 0, payable_paid: payable[paid] or 0, }线上跑的时候我发现一个坑Sum(amount, filterQ(statuspaid))里的filter只支持Django 2.0以上且聚合字段如果所有记录都被过滤掉返回的是None不是0。所以后面加or 0很有必要。4.4 微信小程序端对接与常见适配小程序端用原生写法不依赖uni-app。在utils/request.js里统一封装请求const BASE_URL https://api.example.com; function request(path, method GET, data {}) { const token wx.getStorageSync(token); return new Promise((resolve, reject) { wx.request({ url: BASE_URL path, method, data, header: { Content-Type: application/json, X-Token: token }, success: res { if (res.data.code 0) resolve(res.data.data); else if (res.data.code 401) { wx.removeStorageSync(token); wx.navigateTo({ url: /pages/login/login }); } else reject(res.data.msg); }, fail: reject }); }); }登录时用wx.login()拿code再传给后端换token。后端拿到code后调用微信接口换取openid再在本地签发自己的token。微信的code本来就是一次性凭证有效期只有五分钟所以不要再缓存code。页面适配上的经验也值得记录。微信小程序的wx.request只认https和小程序后台配置的合法域名开发阶段可以在开发者工具里勾选“不校验合法域名”但真机预览必须填真实域名。顶部导航栏的高度不用写死用wx.getMenuButtonBoundingClientRect()获取胶囊位置再估算导航栏高度不同机型都能适配。图片上传用wx.chooseMedia拿到临时文件路径后再用wx.uploadFile传给后端不能直接拿临时路径当永久地址。账单页和工单列表页的缓存我建议不要简单用wx.setStorageSync存原始数据应该带上自定义过期时间戳。比如缓存5分钟启动时先比较时间过期了再重新拉取否则会出现用户已付款但页面还显示未付款的情况。5. 部署与联调从本地跑通到上线踩过的坑5.1 本地开发调试的真实环境联调阶段很多人会卡在小程序访问本地Flask服务这一步。最直接的办法是让手机和电脑在同一个局域网内小程序请求地址用电脑的局域网IP比如192.168.x.x:5000同时开发工具勾选不校验合法域名。如果手机扫码预览也需要打开调试模式否则安卓真机仍然会因为域名没校验而拒连。更省心的方式是买一个带HTTPS的内网映射工具把本地服务的公网地址配到小程序后台开发域名里这样每台电脑都能马上用真机调试。我在项目里用过这种方式省了很多配路由的麻烦。如果你的团队不方便用这类工具至少也要保证测试环境有固定的公网地址不要每天改来改去。后端两个服务同时开发时端口别冲突。Flask我用了5000Django开发服务器用了8000前端小程序根据场景分别请求不同端口。上线后通过nginx统一入口分别代理到两个服务比如/api/repair/*到Flask/api/bills/*到Django。小程序端只保留一个API地址对后续迁移友好。5.2 用gunicorn和nginx上线跑生产环境我建议用gunicorn跑Flask和Django的WSGI应用。Flask侧gunicorn -w 4 -b 127.0.0.1:5000 app:appDjango侧gunicorn -w 4 -b 127.0.0.1:8000 config.wsgi:application两者都监听本机端口nginx在外面做HTTPS和反向代理。不要把gunicorn直接绑到0.0.0.0上让nginx管理对外流量后面加SSL证书、加限流都方便。nginx配置里最重要的一段是转发请求头。小程序端带过来的X-Token必须原样向后传到Django或Flask否则Django的TokenAuthentication会直接401。我吃过这个亏排查了两小时才发现是nginx配置把自定义头给吞了。在location里加一行proxy_set_header X-Token $http_x_token;就解决了。5.3 微信生态里的配置清单上线前微信小程序后台有几项必填request合法域名必须是httpsuploadFile合法域名也要填。如果用了微信支付要开通商户号并把支付回调地址配到服务器上回调地址必须是公网可访问的HTTPS。订阅消息要提前申请模板比如“报修进度通知”“账单缴费提醒”每个模板ID都要在代码里配置好。订阅消息不是用一次就永久有效用户点同意后只能发送一次模板消息。如果报修工单要多次提醒最好在用户每次查看工单时重新发起订阅否则第二次发送会提示“用户未授权”。这个问题很容易被忽略上线后提醒消息会莫名消失。我的解决办法是在工单详情页的onLoad里主动调用wx.requestSubscribeMessage提前拿下一次授权。6. 常见问题与排查实录6.1 小程序连不上后端现象原因排查方法请求直接fail域名没配或未勾选“不校验合法域名”开发者工具打开不校验真机预览配好https域名connect ECONNREFUSED后端服务没启动或者监听端口不对检查本地进程和端口确认防火墙放行请求返回200但code是401token没取到或过期打印请求头里的X-Token确认登录后再请求安卓真机请求失败iOS正常小程序后台域名配置只填了https未填uploadFile域名在平台里把所有域名类型都补全排这种问题时我的习惯是先看小程序的Network面板再去看后端日志。小程序面板里能明确看到请求URL、状态码和返回内容比一点一点猜快很多。6.2 报修工单状态错乱和数据不一致多端同时操作时报修单状态容易错。比如管理员在电脑上把工单指派给师傅师傅在小程序上接单时可能读到的是旧状态。解决办法是在修改状态时带上当前状态条件更新语句写成RepairTicket.query.filter_by(idticket_id, statusold_status).update( {status: new_status} )如果没有匹配行说明状态已经被别人改过返回冲突错误让前端刷新重试。这种乐观锁的做法比单纯加锁要好不会长时间锁表并且很符合工单这种低频更新场景。另一个容易踩的是维修费用生成账单时的一致性。我在Flask里更新工单完成后又去Django那边生成账单早期因为两个服务独立提交出现过工单已完成但账单没生成的情况。后来我把“完成工单”和“生成账单”放到同一个数据库事务里同时更新RepairTicket和Bill如果bill创建失败工单状态也回滚。Flask里用SQLAlchemy的db.session.begin_nested()处理Django那边用transaction.atomic()。6.3 前后端页面显示和缓存问题有段时间租客反馈账单页显示金额不对我把问题缩小到缓存上。小程序从Bill接口拿数据存进wx.setStorageSync但页面打开时没有先清理旧缓存用户付完款后账单状态还是旧的。解决办法是给缓存加版本号和时间戳读取缓存时判断是否过期7天内不过期过期就重新请求。这个设计很简单但很实用。顶部导航栏高度适配是另一个高频问题。小程序iPhone和安卓机的状态栏高度不一样直接用固定的navigationBarHeight会出现标题偏上或偏下。我写了一个工具函数在onLoad里调用const { statusBarHeight } wx.getSystemInfoSync(); const menu wx.getMenuButtonBoundingClientRect(); const navBarHeight (menu.top - statusBarHeight) * 2 menu.height;用动态计算出的高度去布局基本不会被机型问题烦到。如果你的页面有吸底按钮或者自定义导航栏这套动态高度方案能直接复用。6.4 Django删除对象和静态文件注意事项如果你用Django Admin管理后台很容易遇到“删除对象”的问题。我在运营后台给管理员提供了作废账单按钮但一开始是调用delete()物理删除。后来财务审核时发现一张已经被扫码支付的账单被删掉后支付记录在微信商户端还躺着到了月底怎么都对不上。改成状态canceled之后保留账单和支付流水报表里过滤掉已取消的账单账才平下来。Django的静态文件在开发环境没问题上线后常常出现admin样式丢失的情况。原因是DEBUG False后Django不会自动提供静态文件服务需要执行python manage.py collectstatic再在nginx里加静态文件alias。如果你用VSCode写Django模板发现{% static %}标签引用的图片显示不了十有八九是STATICFILES_DIRS没配好或者目录不对。6.5 定时任务和消息推送经验应收应付里最有用的一个功能是逾期提醒。我是用Django的manage.py自定义命令配合crontab每天上午9点执行0 9 * * * cd /opt/lease_project venv/bin/python manage.py send_overdue_reminders命令里查询离到期日3天和已逾期的应收账单再调用微信订阅消息接口给租客推送提醒。这里要注意推送服务如果部署在Flask侧也可以通过HTTP调用但必须处理好重试和日志。微信接口返回失败时不能只print要落到一张message_log表里否则消息丢了完全不知道。7. 项目管理层面的复盘与建议7.1 人和流程的边界从一开始就要定清这套系统最有价值的经验不是代码而是角色边界。租客只在微信小程序里操作运营人员只在Django Admin里操作维修师傅只用一个简化版的小程序页面。每个人看到的界面完全不同数据权限不能混。小程序端的用户是租客天然只能看到自己合同下的数据后台管理员则可以跨房源查看。这些权限规则我一开始没定细结果出现了租客能看到其他房间工单的情况改权限花了半天时间。功能边界也要在开发前划清楚。Flask负责什么、Django负责什么、哪些表只有谁写如果我一开始就定义清楚后面不会出现两边各写一遍导致字段冲突。个人开发者很容易犯这个毛病今天想用Flask实现一切明天觉得Django更全结果代码越来越乱。7.2 小步快跑比一步到位更重要如果你是自己练手可以从“一个Flask项目跑通报修API”开始跑顺了再往里加Django后台和账单模块。不要上来就同时整两个框架我也不是一开始就计划混用的而是在业务自然推进中演变成现在的分工。先让一条业务线闭环再扩展下一条是这类项目最稳的做法。最后再分享一个开发效率技巧小程序端统一封装请求、统一处理401跳转后端每个接口返回{code, msg, data}结构能省掉大量联调扯皮。这个结构看着老土但最好用不管是一个人还是几个人协作接口约定一致比什么都重要。这套系统跑起来之后那家公司的管家再也不用晚上十点对着微信群抄报修记录了。你如果也在做类似的系统照着这套思路把报修和账单串起来基本上一周就能跑通第一版。