上个月朋友在群里甩了一句ChatGPT Plus 一个月二十刀我一个人用属实有点肉疼。底下立马有人接话那咱仨凑一份不就完了。话说得轻巧真操作起来才发现三个人共用一份订阅麻烦的根本不是钱是那本糊涂账——谁先垫的、谁还没给、这个月谁用得多、下次续费谁记得。我前后帮三拨人搭过这类小工具踩的坑比写的代码还多最后干脆把这一套流程整理成了一个能直接跑起来的小项目一个专门管“合租账务 用量统计 到期提醒”的后台。这篇文章就把这套东西从头到尾拆开讲清楚。它解决的核心问题很具体把一份订阅背后的成本、成员、额度、账期这几件事从微信群里的口头约定变成一张能看、能查、能导出的表。适合谁来参考三个人起步的小圈子、几个同事临时组的兴趣小组、想低成本体验 ChatGPT Plus 又不想一个人扛全价的人。不需要你是后端大佬会点 Python能跑通一个单文件服务这套东西就能用起来。下面我按“为什么要做、怎么设计、怎么写、怎么避坑”的顺序把每一层都摊开说。1. 先算清楚一笔账单人订阅到底贵在哪1.1 一份订阅的真实成本构成很多人对 ChatGPT Plus 的价格印象停留在“20 美元一个月”但实际掏钱的时候成本并不止这一项。按官方页面公布的定价Plus 是 20 美元/月的档位年付方案折算下来大约是 200 美元/年也就是相当于打了两个月左右的折扣。再加上支付环节可能产生的手续费、汇率换算的损耗实际到手价往往会比票面数字高一点点。我把这笔账拆成三块来看。第一块是票面价也就是官方标价这部分透明谁都能查到。第二块是支付成本包括跨境支付的通道费用、货币转换的价差这部分通常占 1% 到 3%看着不多一年累积下来也够吃两顿饭。第三块是隐性成本也就是你要花时间去处理续费、对账、催款这些事情如果按时间折算反而是最贵的一块。单人用的时候这三块成本全压在一个人身上。年付 200 美元换算成人民币按 7 左右的汇率估算大概在 1400 元上下平均每月一百出头。对重度使用者来说这笔钱花得值但如果你一周只问几个问题那这份订阅的性价比就明显撑不住了。这也是为什么“拼车”这件事一直有人干——不是想占便宜是单纯的边际使用量撑不起全额费用。1.2 三个人分摊之后每人每月掏多少钱这里我列一张表把不同人数、不同付费周期下的单人成本算清楚。汇率我统一按 7 估算实际支付以官方页面和你的支付渠道为准这个数字只是用来做决策参考。方案总价美元/年折合人民币约3 人均摊元/月5 人均摊元/月月付240168046.728.0年付200140038.923.3从表里能看出来两个结论。第一年付比月付划算三个人均摊的情况下每人每月大约 39 元比月付少了七八块一年下来接近一百块的差距。第二人数从 3 增加到 5人均成本下降的幅度并不算特别大但管理复杂度会明显上升。所以我的建议是起步阶段 3 到 4 个人最合适超过 6 个人之后再考虑上工具否则光是拉群对账就能让你崩溃。还有一个容易被忽略的点年付是一次性掏一大笔钱。如果由一个人先垫付那这个人承担的资金压力其实不小而且一旦中途有人退出退款这件事基本没法处理。所以我更推荐的做法是管理员先统一付款然后把年费切成 12 期成员按月给管理员转账。这样垫资方的压力被摊平了成员的心理负担也小谁想退出只要提前一个月说一声就行。1.3 省钱之外拼车真正难的是管理钱算清楚只是第一步。真正让拼车这件事翻车的永远是管理环节。我见过太多这样的场景群里五个人付款的是 A但 A 从来不做记录三个月后问起来B 说“我记得我给过”C 说“我转的是支付宝还是微信来着”D 直接不吭声。最后 A 一算账发现自己垫了四个月的钱还不好意思开口催。第二个高频问题是用量不均。一份订阅在一个月里的可调用量是有上限的官方会不定期调整具体额度比如消息条数、高级模型的使用次数、文件上传的量级等等。三个人里如果有一个人天天高强度使用另外两个人偶尔才问一句那么这两个人心里就会开始打鼓我是不是在替别人付钱这种情绪一旦出现续费的时候必然有人退出。第三个问题是交接混乱。续费日是什么时候、谁负责付、付完之后要不要重新登录、密码改了要不要通知、有人换手机了怎么办——这些事情如果没有一个统一的记录点每次都要靠群聊翻记录效率极低。所以这个项目的真正价值不在于帮你省钱而在于把这堆琐碎的事务从人的记忆里搬到一张表里让每一次付款、每一次用量、每一次人员变动都有据可查。2. 项目的功能边界与技术选型2.1 做什么、不做什么先把边界划清楚我在动手之前先画了一条线明确这个项目只解决三件事账务、用量、提醒。账务负责记录谁付了多少钱、账期从哪天到哪天、当前还欠多少用量负责记录每个成员在这个周期内用了多少、有没有超出约定份额提醒负责在到期前几天自动发通知避免服务中断的尴尬。不做什么也很重要。这个工具不存储任何账号凭证不参与任何支付通道的对接也不做任何形式的二次转售。凭证由管理员自己保管工具里只记录成员姓名和分摊金额。这条边界既是出于安全考虑也是为了让整个系统的复杂度降下来——你不需要处理加密存储、权限隔离这些麻烦事几十行代码就能跑起来。另外我在设计时会优先推荐成员走官方提供的多人席位方案因为那是最省心的路径座位天然隔离每个人都用自己的凭据登录不存在互相干扰的问题。但现实中很多小圈子只想先低成本体验一下那么这套账务工具依然适用因为它本质上管的是“钱和时间”跟底层用什么方案无关。你把它理解成一个轻量的 AA 记账本专门为订阅类消费做的定制版会更贴切。2.2 技术栈选择为什么我用 FastAPI 加 SQLite选型的核心原则是“部署成本要低到一个人能维护”。基于这个原则我把候选方案过了一遍。方案上手难度部署成本适合规模我的评价FastAPI SQLite低一台入门云主机即可3-20 人首选单文件可跑迁移方便Node.js 低代码平台中需要配置多服务10 人以上灵活但维护成本高纯表格 手动维护极低无3 人以内人一多就失控容易算错FastAPI 的优势在于写起来快、自带的接口文档省掉了前后端联调的沟通成本。SQLite 则是被低估的选手它的并发能力应付几十个人的记账场景绰绰有余而且整个数据库就是一个文件备份的时候直接复制走就行根本不需要折腾数据库服务。如果人数确实上到了十几二十个把 SQLite 换成 PostgreSQL 也只是改一个连接字符串的事代码层面几乎不用动。这也是我愿意用轻量方案起步的原因——先跑起来再按需升级而不是一开始就堆一堆组件最后没人愿意维护。2.3 目录结构与模块划分整个项目我按功能切成四块目录大致长这样carpoold/ ├── app/ │ ├── main.py # 接口入口 │ ├── models.py # 数据模型 │ ├── billing.py # 分摊算法 │ ├── usage.py # 用量统计 │ └── notify.py # 提醒推送 ├── templates/ │ └── dashboard.html # 账目公示页 ├── data/ │ └── pool.db # SQLite 数据库文件 └── requirements.txt这么分的理由是账务算法和用量统计是最容易出 bug 的两块单独拆出来之后可以写单元测试改算法的时候不用担心影响接口层。提醒模块单独放是因为它依赖外部推送通道将来换成别的通知方式时只需要改这一个文件。模板文件只有一个就是一页账目公示页成员打开浏览器就能看到自己欠多少钱不需要登录也不需要装 App。3. 核心数据模型与费用分摊算法3.1 四张表撑起整个系统数据库我设计了四张表分别对应成员、账期、分摊明细和用量记录。表结构不复杂但每张表都留了扩展字段后面加功能的时候不用改表。CREATE TABLE members ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, contact TEXT, join_date TEXT NOT NULL, leave_date TEXT, share_ratio REAL DEFAULT 1.0, status TEXT DEFAULT active ); CREATE TABLE bills ( id INTEGER PRIMARY KEY AUTOINCREMENT, period_start TEXT NOT NULL, period_end TEXT NOT NULL, total_amount REAL NOT NULL, currency TEXT DEFAULT CNY, paid_by INTEGER NOT NULL, paid_at TEXT, FOREIGN KEY (paid_by) REFERENCES members(id) ); CREATE TABLE shares ( id INTEGER PRIMARY KEY AUTOINCREMENT, bill_id INTEGER NOT NULL, member_id INTEGER NOT NULL, amount REAL NOT NULL, settled INTEGER DEFAULT 0, settled_at TEXT, FOREIGN KEY (bill_id) REFERENCES bills(id), FOREIGN KEY (member_id) REFERENCES members(id) ); CREATE TABLE usages ( id INTEGER PRIMARY KEY AUTOINCREMENT, member_id INTEGER NOT NULL, period TEXT NOT NULL, used_units REAL DEFAULT 0, reported_at TEXT, FOREIGN KEY (member_id) REFERENCES members(id) );members表里的share_ratio是个关键字段默认是 1.0代表标准份额。如果某个人中途加入或者只打算用半个月把比例调成 0.5 就行分摊的时候会自动按比例算。leave_date用来记录退出时间退出之后的分摊会自动停止。shares表是整套系统的核心它把每一期账单拆成若干条分摊记录每条记录对应一个人该付多少钱、有没有结清。这样查询“谁还欠钱”只需要一句 SQL不用在应用层做复杂的遍历。3.2 三种分摊模型怎么选分摊方式我准备了三种实际用下来各有适用场景。第一种是按人头均摊。这是最简单也最不容易吵架的方式不管谁用得多谁用得少每人出一份。适合人数少、彼此关系好、用量差距不明显的小圈子。它的优点是不需要统计用量省掉了一整块功能缺点是当一个成员长期不用的时候他会觉得不值。第二种是按用量加权。每个人按自己实际使用的量占比来分摊用得多的多掏。听起来公平但实际上很容易引发争议因为“用量”的定义本身就很模糊——发一条消息和上传一份文件消耗完全不同成员之间为了几块钱的差额争论不休反而伤感情。第三种是混合模式也是我最终推荐的方式把总费用拆成两部分六成按人头均摊四成按用量加权。这样既保证了基础的公平感又给用量大的成员留了一个补偿机制。公式很简单个人分摊额 总额 × (0.6 / 人数 0.4 × 个人用量 / 总用量)这个比例可以按圈子的实际情况调比如大家关系特别铁那就把固定部分调到 0.8如果都是重度用户用量差距很大那就把浮动部分加大。我的经验是固定部分不要低于 0.5否则账目会算得非常碎得不偿失。3.3 分摊算法实现含代码下面这段代码是核心分摊逻辑输入账单总额和成员列表输出每个人的分摊金额。这里我做了几个边界处理中途加入的成员按天折算已经退出的成员不参与分摊总金额的尾差会补给付款人。from datetime import date def calc_shares(total_amount, members, period_start, period_end, usage_mapNone): members: [{id:1, name:A, ratio:1.0, join_date:2024-01-01, leave_date:None}] usage_map: {member_id: used_units} p_start date.fromisoformat(period_start) p_end date.fromisoformat(period_end) total_days (p_end - p_start).days 1 effective [] for m in members: j date.fromisoformat(m[join_date]) l date.fromisoformat(m[leave_date]) if m.get(leave_date) else p_end start max(j, p_start) end min(l, p_end) if start end: continue active_days (end - start).days 1 weight (active_days / total_days) * m.get(ratio, 1.0) effective.append({**m, weight: weight}) if not effective: return [] total_weight sum(m[weight] for m in effective) usage_map usage_map or {} total_usage sum(usage_map.get(m[id], 0) for m in effective) or 1 result [] for m in effective: fixed 0.6 * m[weight] / total_weight dynamic 0.4 * usage_map.get(m[id], 0) / total_usage if total_usage else 0 amount round(total_amount * (fixed dynamic), 2) result.append({member_id: m[id], name: m[name], amount: amount}) diff round(total_amount - sum(r[amount] for r in result), 2) if result and diff ! 0: result[0][amount] round(result[0][amount] diff, 2) return result这里有两个细节值得展开讲。第一个是weight的计算它把“按比例分摊”和“按天折算”统一成了一个权重值这样中途加入的人不需要单独写分支逻辑。比如一个人是月中加入的他这一期的权重就是 0.5分摊的时候自动减半不用手动调数字。第二个是尾差处理。浮点数累加之后总会有几分钱的误差如果不处理账目对不上成员一看就会质疑。我把差额直接补给分摊列表里的第一个人通常就是付款的那个管理员几分钱的事既简单又不会引起争议。实测下来这个做法比强行凑整要省心得多。4. 从零搭起来接口开发与自动提醒4.1 环境准备与项目初始化先装依赖三个包就够了python -m venv venv source venv/bin/activate pip install fastapi uvicorn apscheduler然后写一个最小的启动文件把数据库初始化和接口挂载起来# app/main.py from fastapi import FastAPI, HTTPException from pydantic import BaseModel import sqlite3, os DB os.path.join(os.path.dirname(__file__), .., data, pool.db) app FastAPI(titleCarPool Bill Manager) def get_db(): conn sqlite3.connect(DB) conn.row_factory sqlite3.Row return conn app.on_event(startup) def init_db(): os.makedirs(os.path.dirname(DB), exist_okTrue) conn get_db() conn.executescript(open(schema.sql).read()) conn.commit() conn.close()row_factory sqlite3.Row这一句千万别漏它让查询结果可以按字段名取值不用记列的顺序写业务代码的时候能省掉大量调试时间。另外数据库文件我放在data/目录下跟代码分开备份和迁移的时候只要拷这一个文件。部署的时候用uvicorn app.main:app --host 0.0.0.0 --port 8000就能跑起来。如果想让它在后台常驻用systemd写一个服务单元或者套一层nohup也行。我一般会在前面挂一个轻量的反向转发服务来处理域名和证书这部分按你的实际环境来配不做展开。4.2 成员加入与账单生成接口成员管理这块只需要两个接口新增成员和查询列表。真正有分量的是账单生成它要把前面算好的分摊结果写进shares表。class BillIn(BaseModel): period_start: str period_end: str total_amount: float paid_by: int app.post(/bills) def create_bill(data: BillIn): conn get_db() members [dict(r) for r in conn.execute( SELECT * FROM members WHERE statusactive).fetchall()] usage_rows conn.execute( SELECT member_id, SUM(used_units) AS u FROM usages WHERE period ? AND period ? GROUP BY member_id, (data.period_start, data.period_end)).fetchall() usage_map {r[member_id]: r[u] for r in usage_rows} shares calc_shares(data.total_amount, members, data.period_start, data.period_end, usage_map) cur conn.execute( INSERT INTO bills (period_start, period_end, total_amount, paid_by, paid_at) VALUES (?, ?, ?, ?, datetime(now)), (data.period_start, data.period_end, data.total_amount, data.paid_by)) bill_id cur.lastrowid for s in shares: conn.execute( INSERT INTO shares (bill_id, member_id, amount) VALUES (?, ?, ?), (bill_id, s[member_id], s[amount])) conn.commit() conn.close() return {bill_id: bill_id, shares: shares}这段代码有两个我做过的取舍。第一个是账单生成的时候直接把分摊明细落库而不是每次查询的时候现算。原因是账单一旦生成就应该是“冻结”的后面有人改了用量或者改了成员状态都不能影响已经发出去的那一期账。这个原则在记账系统里非常重要否则前后两次查询结果不一致成员会直接质疑你的系统有问题。第二个取舍是paid_at直接取当前时间不做单独设置。这是为了让管理员的垫资时间点有据可查将来如果有人问“这笔钱什么时候垫的”翻账单就能看到。如果确实需要补录历史账单把这一列改成参数即可改动量很小。4.3 用量上报与统计用量这部分我做过一次大的方案调整。最初的设计是自动抓取后来发现成本太高改为自助上报加权重的模式成员每次使用后手动打个卡或者每周填一次大概的量系统按上报值计算浮动分摊。class UsageIn(BaseModel): member_id: int period: str used_units: float app.post(/usages) def report_usage(data: UsageIn): conn get_db() conn.execute( INSERT INTO usages (member_id, period, used_units, reported_at) VALUES (?, ?, ?, datetime(now)), (data.member_id, data.period, data.used_units)) conn.commit() conn.close() return {ok: True}上报的口径要提前约定好比如统一按“消息条数”计数上传文件算 3 条长文本推理算 2 条。这个口径不用特别精确因为它只影响 40% 的浮动部分误差几块钱没人会真的计较。但如果口径不统一A 按条数算、B 按次数算那账就彻底乱了。我的做法是在公示页顶部固定写一行口径说明谁想改就开会讨论改完统一生效。如果你们底层用的是按量计费的接口方案那统计可以完全自动化直接读后台的调用记录把 token 数换算成单位量即可。这种情况下反而更省事因为数据是客观的不存在争议。只是要注意做好数据脱敏只统计数量不要在公示页里暴露任何请求内容。4.4 到期提醒与催缴通知提醒模块用 APScheduler 跑定时任务每天早上检查一次发现有账单快到期或者有人还没结清就推一条消息到群里。from apscheduler.schedulers.background import BackgroundScheduler from datetime import date import requests def daily_check(): conn get_db() today date.today().isoformat() unpaid conn.execute( SELECT m.name, s.amount, b.period_end FROM shares s JOIN members m ON m.id s.member_id JOIN bills b ON b.id s.bill_id WHERE s.settled 0).fetchall() conn.close() if unpaid: lines [f{r[name]} 待结清 {r[amount]} 元 for r in unpaid] push(\n.join(lines)) def push(text): webhook https://your-bot-endpoint requests.post(webhook, json{msgtype: text, text: {content: text}}, timeout5) scheduler BackgroundScheduler() scheduler.add_job(daily_check, cron, hour10) scheduler.start()推送通道随便选群机器人、邮件、短信都可以我这里用的是群机器人的自定义接口改一个地址就能用。提醒的时机我设了两个续费日前 7 天推一条“准备续费”续费日前 1 天推一条“明天到期”。实测下来 7 天这条最有用它给了大家足够的时间把钱转过来不至于最后一天临时抱佛脚。注意定时任务里的时间要用服务器本地时间如果你的服务器在别的时区记得在启动脚本里设置好否则提醒可能在半夜发出来效果适得其反。5. 上线之后常见问题与避坑清单5.1 问题速查表跑了一段时间之后我把遇到的典型问题整理成了一张表出问题的时候直接查比翻日志快得多。现象可能原因排查方向处理建议分摊金额总和对不上浮点累加误差检查尾差补偿逻辑差额补给付款人保留两位小数某人分摊为 0加入日期晚于账期结束查 members 表的时间字段核对加入时间按天折算提醒没发出来定时任务未启动或时区错位查看调度器日志显式设置时区并打印执行记录用量统计偏差大上报口径不统一比对公示页的口径说明固定口径开会统一后生效账单重复生成接口被重复调用查 bills 表的账期字段对账期加唯一约束防止重复插入这张表里最值得说的是最后一条。我一开始没加约束结果有次误触同一个账期生成了两条账单成员收到两条催缴通知群里直接炸锅。后来我在bills表上加了UNIQUE(period_start, period_end)约束这个问题就再也没出现过。类似的小坑还有好几个都是靠实际运行才暴露出来的。5.2 我踩过的几个坑第一个坑是汇率处理。最开始我按固定汇率算账结果有个月汇率波动比较大实际支付比预算多出几十块这笔差额没人愿意认。后来改成按支付当天的实际汇率记账把汇率和金额一起存进账单表公示页上直接显示“本期汇率 X”争议立刻消失了。第二个坑是中途退出的处理。有个成员用了两个月说不想续了当时我按整月算的分摊他觉得应该按天算。最后虽然按天补了差价但过程很不愉快。现在我直接在算法里做了按天折算任何人在账期中途退出系统自动按实际天数算不需要人工介入也就没有讨论的余地了。第三个坑是公示页的隐私问题。我一开始把所有成员的待缴金额都列出来结果有人觉得自己的欠款金额被公开了心里不舒服。后来改成只显示“本人”的金额其他人只显示是否结清的状态不给具体数字。这个改动看似小但它直接决定了大家愿不愿意继续用这个工具。5.3 合规与安全几条必须守住的线这套工具虽然小但涉及钱和账号有几条线不能碰。第一工具本身不存储任何账号凭据成员信息里只有姓名、联系方式和分摊比例凭证由管理员在官方渠道自行管理工具不参与。第二所有付款都走官方支持的正规支付渠道管理员的角色只是“代收分摊款”不做任何形式的代购、代充或转售。第三公示页只展示与账务相关的必要信息不做任何形式的成员数据导出和二次分发。还有一点要提醒订阅本身有使用规则成员数量、账号使用范围这些约束要以官方说明为准。作为管理工具我们能做的就是把内部账算清楚让每个人知道自己该出多少、什么时候出而不是去想办法绕过规则。这个边界守住了工具才能用得长久一旦越线今天省下的几十块可能明天就要付出更大的代价。另外管理员的两步验证一定要开联系方式不要用过于公开的号码。群里的账单截图记得打码把金额以外的信息遮掉再发。这些都是很小的事但真出事的时候它们就是最后一道防线。6. 进阶玩法从三个人到一个小圈子6.1 角色权限与成员分层人数超过六个人之后单靠一个管理员已经顾不过来了。这时候可以在members表里加一个role字段把成员分成管理员、财务和普通成员三层。管理员负责付款和维护系统财务负责核对账目普通成员只负责上报用量和缴费。权限上普通成员只能看到自己的分摊明细财务能看到全部账目但不能改成员管理员才拥有全部权限。这个分层的意义不在于技术复杂度而在于责任划分。当账目出现争议的时候有明确的角色分工处理效率会高很多。我在一个十人的圈子里试过这套结构效果比所有人都在群里吵要顺畅得多。6.2 账目公示与信任机制最后说一个软性的设计公示页。它的价值不在于展示数据而在于建立信任。当每个人都能随时打开一个页面看到本期总共花了多少、自己分摊多少、已经结清多少、下一期什么时候开始那种“我是不是被坑了”的疑虑就会自然消散。我在公示页上做了三块内容顶部是本期概览中间是个人明细底部是历史账期列表。历史列表可以点开看每一期的分摊记录谁什么时候付的、付了多少清清楚楚。上线之后最直观的变化就是催缴的消息少了一大半因为大家都在同一个页面看到同一份数据没有信息差了。这套东西后续还能继续扩展。比如接入一个简单的时间轴视图把每个成员的缴费记录画成一条线或者加一个导出功能一键生成 CSV 给需要报销的成员用。接口层已经预留了空间加功能的时候不用动核心算法。我个人在实际维护这套工具两年多的时间里最大的体会是技术上的难点其实很少真正难的是把“约定”变成“记录”。只要每一笔钱、每一次用量、每一次人员变动都落到表里剩下的争议都会自然消解。所以别在功能上贪多先把账记对这一步做扎实了后面加什么功能都好办。