eggjs月度账单统计接口:聚合SQL、联合索引与缓存优化 月度账单统计接口这东西乍一看就是一句GROUP BY的事真落到项目里能把人磨掉半层皮。上个月我接手一个后台系统用户端要展示本月收入多少、支出多少、钱主要花在哪些类目、每天的走势怎么样运营端还要看 Top 商户排行和环比变化。业务方丢过来一句话——eggjs 怎么实现月度账单统计接口剩下的全靠自己拆。eggjs 这套框架本身不复杂Router、Controller、Service 三件套摆在那儿难的是统计口径怎么定、时间区间怎么切、金额用什么类型存、聚合 SQL 怎么写才不把数据库拖死。这篇就把我这一路拆下来、调下来、修下来的东西完整摊开讲从表结构设计一直到缓存失效策略含可复制的代码和踩过的坑。前端同学可以看口径部分后端同学可以直接抄 Service 层刚接触 eggjs 的新手也能顺着路由那条线往下读。1. 先把月度账单统计这件事拆开1.1 业务方嘴里的统计通常包含四块内容接到月度账单统计这个需求时我第一件事不是打开编辑器而是拿张纸把业务方真正想看的东西列出来。绝大多数情况下它不是一个接口而是四个视角的叠加当期汇总这个月一共收入多少、支出多少、净额多少、多少笔、日趋势每一天的收支曲线用来画折线图、类目分布餐饮、交通、购物各占多少用来画饼图、排行明细Top N 商户或 Top N 大额账单。为什么要拆成四块因为它们的数据形态完全不同。汇总是一行数据日趋势是 28 到 31 行类目分布是若干个分组排行是带明细的列表。如果硬塞进一个接口返回SQL 要么写成四次查询拼装要么写成一条巨复杂的多结果集。我个人的选择是一个接口、一次请求、内部四次查询因为它们共享同一套筛选条件用户 ID、月份、币种前端一个请求拿全量数据最省事减少三次网络往返。这里有个取舍要说清楚如果你这个接口 QPS 很高、或者日活用户几十万那一个接口塞四份数据反而不划算因为很多人只打开首页看汇总根本不看趋势图。这种场景我建议拆成/bill/month/summary和/bill/month/detail-charts两个接口按需加载。判断标准很简单——看前端是不是同一屏渲染。同一屏就合分屏就拆。1.2 为什么我坚持把统计逻辑压在 Service 层eggjs 的分层约定很清晰但新手容易把统计 SQL 直接写在 Controller 里理由是就一个接口懒得建 Service。这种写法在小项目里能跑一旦需求变成App 端要一份、小程序端要一份、运营后台要一份、还要导出一份 Excel你就会发现自己要复制四遍同样的聚合逻辑改一个统计口径要改四个文件。我的做法是 Service 层只暴露一个方法getMonthlyStat({ userId, month, bizType })返回一个结构固定的对象Controller 负责参数校验和响应包装导出任务、定时报表、内部 RPC 全部复用这一个方法。这样做的额外好处是口径统一——统计最容易出的事故就是App 显示的收入是 12 万后台导出的收入是 11.8 万原因往往就是两边各写了一遍 SQL条件差了一个status 1。还有一层考虑eggjs 的ctx贯穿整个请求链路Service 里可以直接拿this.ctx.model、this.ctx.app.redis、this.logger如果你把 Service 方法设计成只接收业务参数不接收 ctx后续想抽到独立的 npm 包里做单元测试就会很别扭。所以我习惯让 Service 方法签名保持纯业务参数内部通过this.ctx访问框架能力测试时用app.mockContext()就能跑。1.3 表结构与索引一次决定后面几年都受用统计接口的性能八成在表设计阶段就定死了剩下两成才是 SQL 优化。我见过太多项目把账单表设计成一个万能流水表字段三十多个索引五六个最后统计查询走不到索引一个月的数据扫出来要三秒。我的账单流水表核心字段控制在下面这些字段名类型说明备注idBIGINT UNSIGNED主键自增或雪花二选一order_noVARCHAR(40)业务单号唯一索引用于幂等user_idBIGINT UNSIGNED用户 ID必带索引的第一列biz_typeSMALLINT业务类型餐饮、交通、购物等枚举directionTINYINT收支方向1 收入-1 支出amountBIGINT金额分绝对不用 DECIMAL 之外的浮点statusTINYINT状态1 成功2 退款中3 已退款0 处理中merchant_idBIGINT UNSIGNED商户 ID排行统计用settle_timeDATETIME结算时间统计的基准时间created_atDATETIME创建时间框架自动维护关键点有两个。第一统计基准时间一定要单独一个字段不能图省事直接用created_at。账单的创建时间和结算时间是两回事一笔 1 月 31 号晚上 23:50 下单、2 月 1 号 00:05 结算的单子到底算哪个月业务方必须给个明确答复。绝大多数财务口径认结算时间所以统计一律走settle_time。第二索引要按查询条件的顺序来建用户 时间范围是最典型的模式所以建一个联合索引idx_user_settle (user_id, settle_time)如果需要按类型过滤再扩成(user_id, biz_type, settle_time)把选择性低但又必带的biz_type放中间。提示联合索引的列顺序要按等值在前、范围在后排。user_id是等值settle_time是范围所以settle_time必须放最后biz_type放中间才不会让后面的范围条件失效。2. 统计口径里的那些隐形地雷2.1 金额存分不存元这一条不接受讨论只要涉及钱FLOAT和DOUBLE就是灾难。0.1 0.2 ! 0.3这个经典问题在财务场景里会直接变成对账对不上。我的方案是数据库存 BIGINT 的分接口返回时统一转成元的字符串保留两位小数。为什么返回字符串而不是 Number因为 JavaScript 的 Number 是双精度浮点安全整数上限是 2^53 - 1约等于 9 千万亿分也就是 900 万亿亿……理论上够用但一旦前端要参与运算比如累加多个类目的金额浮点误差立刻出现。返回字符串让前端用decimal.js或者直接展示反而更安全。如果你们用的是 SequelizeBIGINT 字段默认会被解析成 JS 的 Number 或者字符串取决于配置。我在config/config.default.js里会把大整数的解析策略明确固定下来// config/config.default.js module.exports { sequelize: { dialect: mysql, timezone: 08:00, // 关键告诉驱动按东八区读写 dialectOptions: { dateStrings: true, // 关键把 DATETIME 读成字符串避免时区二次转换 typeCast: true, supportBigNumbers: true, bigNumberStrings: true, // BIGINT 直接给字符串不在 JS 里丢精度 }, define: { freezeTableName: true, timestamps: true, createdAt: created_at, updatedAt: updated_at, }, }, };dateStrings: true这一行我吃过亏。不加它MySQL 驱动会先把DATETIME转成 JS 的Date对象再按 Node 进程的本地时区格式化如果服务器时区是 UTC 而业务是东八区你会看到所有统计结果整体偏移 8 小时月底最后一天的账单会跑到下个月去。加上之后读出来就是2025-01-31 23:50:00这样的原始字符串谁也别想偷偷改它。2.2 月边界的半开区间写法统计区间我从不使用BETWEEN一律用半开区间 start AND end。原因很直接DATETIME如果只有秒级精度BETWEEN 2025-01-01 00:00:00 AND 2025-01-31 23:59:59会漏掉23:59:59.500这种毫秒级时间戳如果有DATETIME(3)那问题更严重。半开区间没有这个边界漏洞。月份区间的计算也别偷懒用new Date(2025-01)。在 Node 里new Date(2025-01)会按UTC解析成2025-01-01T00:00:00Z在东八区它就是 1 月 1 号早上 8 点直接错位。我的写法是纯字符串拼接不经过 Date 对象// app/service/bill.js 里的私有方法 _monthRange(month) { // month 形如 2025-02已由参数校验保证格式 const [year, mon] month.split(-).map(Number); const pad (n) String(n).padStart(2, 0); const start ${year}-${pad(mon)}-01 00:00:00; const nextYear mon 12 ? year 1 : year; const nextMon mon 12 ? 1 : mon 1; const end ${nextYear}-${pad(nextMon)}-01 00:00:00; return { start, end }; }用下个月 1 号 0 点作为开区间上界这套写法自动处理了 28 天、29 天、30 天、31 天闰年二月也不用特判。十二月加一的进位也顺手做了。2.3 退款、冲正与跨月回冲必须提前和业务方对齐这是统计接口最容易引发争议的地方。一笔 1 月的消费2 月 3 号退款那 1 月的支出还算不算2 月的收入要不要扣我遇到过三种口径都被不同业务方要求过按结算时间入账退款单独生成一条direction 1的流水记在退款当月。1 月支出不变2 月出现一笔收入。这是最干净的做法对账清晰也最容易被开发接受。原单冲销退款时把原单的status改成已退款统计时用status 1过滤掉。问题是 1 月的数字会在 2 月发生变化用户回头去看 1 月账单会发现金额变了容易引发投诉。双轨制原单保留另建一张退款表统计时把两张表 union 起来。灵活但查询复杂度翻倍。我现在的默认选择是第一种同时在表里保留refund_of字段记录原单号方便追溯。这里必须提醒一句接口文档里一定要把这条口径写清楚最好在返回体里带上statRule: settle_time这类字段。我见过一个项目因为口径没写清楚前端产品以为退款不进统计自己在前端又扣了一遍最后 App 上显示的数字比实际少了一半排查了两天才定位。3. 接口完整实现从路由到 SQL3.1 路由注册与参数校验eggjs 的 Router 写法很固定我习惯把账单相关的路由聚合在一个文件里// app/router/bill.js module.exports (app) { const { router, controller } app; router.get(/api/bill/monthly-stat, controller.bill.monthlyStat); };参数校验用egg-validate基于 parameter 库规则写在 Controller 里。这里要注意一点egg-validate在校验失败时是直接抛 422 的如果你希望统一返回结构需要在中间件里捕获err.status 422并包装成自己的格式。// app/controller/bill.js const Controller require(egg).Controller; class BillController extends Controller { async monthlyStat() { const { ctx, service } this; const rule { userId: { type: string, required: true, allowEmpty: false }, month: { type: string, required: true, allowEmpty: false, format: /^\d{4}-(0[1-9]|1[0-2])$/, }, bizType: { type: string, required: false }, // 逗号分隔可选 }; ctx.validate(rule, ctx.query); const { userId, month } ctx.query; const bizType ctx.query.bizType ? ctx.query.bizType.split(,).map(Number) : null; const data await service.bill.getMonthlyStat({ userId, month, bizType }); ctx.body { code: 0, msg: ok, data }; } } module.exports BillController;月份格式用正则卡死在YYYY-MM不合法请求在进入 Service 之前就被拦掉。这个正则看着小事实际上能挡掉一堆脏数据——我见过接口被扫描器灌了monthundefined和month2025-2两种值如果没有校验后面拼出来的 SQL 时间范围会是NaN-undefined-01虽然参数化查询不会注入但语义完全错了。3.2 汇总查询一条 SQL 出四个数汇总部分我用一条 SQL 同时算收入、支出、笔数避免三次往返// app/service/bill.js 片段 async getMonthlyStat({ userId, month, bizType }) { const { start, end } this._monthRange(month); const { Sequelize } this.app; const { fn, col, literal } Sequelize; const where { user_id: userId, status: 1, // 只统计成功的账单 settle_time: { [Sequelize.Op.gte]: start, [Sequelize.Op.lt]: end, }, }; if (bizType bizType.length) { where.biz_type { [Sequelize.Op.in]: bizType }; } const summary await this.app.model.Bill.findOne({ attributes: [ [fn(COALESCE, fn(SUM, literal(CASE WHEN direction 1 THEN amount ELSE 0 END)), 0), income], [fn(COALESCE, fn(SUM, literal(CASE WHEN direction -1 THEN amount ELSE 0 END)), 0), expense], [fn(COUNT, col(id)), count], ], where, raw: true, }); // ... }这里有两个细节值得单独说。第一COALESCE不能省。如果这个月用户一笔账单都没有SUM返回的是NULL前端拿到null再做减法就是NaN页面上会出现刺眼的红字。加上COALESCE(..., 0)之后永远返回数字。第二raw: true要开。不开的话 Sequelize 会帮你实例化一堆 Model 对象统计接口根本不需要这些对象纯粹是浪费内存和 CPU。数据量大时这个差别能到几十毫秒。如果你用的不是 Sequelize而是egg-mysql直接写 SQL那就更直白const row await this.app.mysql.get(biz_bill, {}) // 不行这个只能单行 // 正确做法 const rows await this.app.mysql.query( SELECT COALESCE(SUM(CASE WHEN direction 1 THEN amount ELSE 0 END), 0) AS income, COALESCE(SUM(CASE WHEN direction -1 THEN amount ELSE 0 END), 0) AS expense, COUNT(*) AS cnt FROM biz_bill WHERE user_id ? AND status 1 AND settle_time ? AND settle_time ?, [userId, start, end] );egg-mysql的query方法内部走的是参数化预编译?占位符会自动转义不用担心注入。唯一要注意的是别用??去拼表名或者列名那种场景只能白名单校验后手动拼接。3.3 日趋势与类目分布分组查询的两个陷阱日趋势的 SQL 核心是GROUP BY DATE(settle_time)const trend await this.app.model.Bill.findAll({ attributes: [ [fn(DATE, col(settle_time)), day], [fn(SUM, literal(CASE WHEN direction 1 THEN amount ELSE 0 END)), income], [fn(SUM, literal(CASE WHEN direction -1 THEN amount ELSE 0 END)), expense], ], where, group: [fn(DATE, col(settle_time))], order: [[fn(DATE, col(settle_time)), ASC]], raw: true, });陷阱一GROUP BY之后不会有空日期。如果用户 1 月 5 号到 8 号一笔账都没记SQL 返回的结果里就没有这四天前端画折线图时横轴会突然断一截。解决办法是在 Service 里做一次补零先按月份天数生成完整的日期数组再用 Map 把查询结果映射进去。_fillTrendDays(month, rows) { const [year, mon] month.split(-).map(Number); const daysInMonth new Date(year, mon, 0).getDate(); // 注意mon 是 1-12这里不用减 1 const map new Map(rows.map(r [String(r.day).slice(0, 10), r])); const result []; for (let d 1; d daysInMonth; d) { const key ${year}-${String(mon).padStart(2, 0)}-${String(d).padStart(2, 0)}; const hit map.get(key); result.push({ date: key, income: hit ? Number(hit.income) : 0, expense: hit ? Number(hit.expense) : 0, }); } return result; }new Date(year, mon, 0)这个写法很取巧第二个参数传mon1-12第三个参数传 0得到的就是下个月的第 0 天也就是本月最后一天。二月自动是 28 或 29不用自己判断闰年。这是我在项目里用得最多的日期小技巧之一。陷阱二DATE()函数会让索引失效。好在GROUP BY的场景下WHERE条件已经把范围锁定在一个月内最多几万行扫这点数据 MySQL 扛得住。但如果你要查一整年的趋势就不能按月扫了那种场景必须上预聚合表后面第 4 节会讲。类目分布的写法类似只是分组字段换成biz_type同时要 join 一张字典表拿中文名。如果字典表很小几十条我更倾向在 Service 层用内存 Map 做映射省一次 joinconst BIZ_TYPE_MAP { 1: 餐饮, 2: 交通, 3: 购物, 4: 娱乐, 5: 生活缴费, }; // 查询后 const distribution rows.map(r ({ bizType: r.biz_type, bizName: BIZ_TYPE_MAP[r.biz_type] || 其他, amount: Number(r.amount), percent: total 0 ? Number(((r.amount / total) * 100).toFixed(2)) : 0, }));percent保留两位小数这点别省略前端直接展示33.3333333%会让人觉得很粗糙。3.4 Controller 组装与序列化Service 返回的四块数据最终要拼成一个响应体{ code: 0, msg: ok, data: { month: 2025-01, statRule: settle_time, summary: { income: 12345.67, expense: 8901.23, net: 3444.44, count: 128 }, trend: [ { date: 2025-01-01, income: 0.00, expense: 128.00 }, ... ], distribution: [ { bizType: 1, bizName: 餐饮, amount: 3200.00, percent: 36.01 }, ... ], topMerchants: [ { merchantId: 1001, merchantName: 某商户, amount: 800.00 }, ... ] } }金额统一用分转元的 helper返回字符串而不是数字// app/extend/helper.js module.exports { fenToYuan(fen) { const n Number(fen) || 0; const sign n 0 ? - : ; const abs Math.abs(n); return ${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, 0)}; }, };这里我特意不用(n / 100).toFixed(2)。toFixed在某些边界值上会有银行家舍入的争议比如1.005.toFixed(2)得到1.00虽然金额已经是整数分不会出现这个问题但整数除法和取余的组合更直白一眼就能看出逻辑也方便以后做千分位格式化。净额可能为负所以符号要单独处理。还有一个容易忽略的点ctx.body赋值后 eggjs 会自动 JSON 序列化如果响应里有BigIntJSON.stringify会直接抛TypeError: Do not know how to serialize a BigInt。这就是为什么我在配置里开了bigNumberStrings: true从驱动层就把 BIGINT 转成字符串避免在序列化阶段炸掉。4. 性能别等报表卡了才想起缓存4.1 缓存的三种失效策略我选哪种月度统计是典型的读多写少、容忍秒级延迟场景非常适合缓存。key 的设计要考虑三件事用户、月份、筛选条件。bill:stat:{userId}:{month}:{bizTypeHash}我一般不把bizType拼进 key因为带类型筛选的请求占比不到 5%为它们单独开一套缓存反而占内存。策略上我是这么处理的不带筛选条件的请求走缓存带筛选条件的请求直接查库。失效时间用egg-redis设置TTL 设为到本月底再加 1 小时async _cacheTtlSeconds(month) { const { end } this._monthRange(month); const endTs new Date(end.replace(/-/g, /)).getTime(); const now Date.now(); const diff Math.floor((endTs - now) / 1000) 3600; return diff 0 ? diff : 3600; // 历史月份给个兜底1 小时 }注意end.replace(/-/g, /)这里的小动作。new Date(2025-02-01 00:00:00)在部分 Node 版本和运行环境下解析行为不一致有的按 UTC有的直接 Invalid Date替换成2025/02/01 00:00:00之后就稳定按本地时间解析。这种看起来没必要但能规避玄学问题的写法我建议保留。缓存写const cacheKey bill:stat:${userId}:${month}; const cached await this.app.redis.get(cacheKey); if (cached) return JSON.parse(cached); const data await this._buildStat(...); const ttl await this._cacheTtlSeconds(month); await this.app.redis.setex(cacheKey, ttl, JSON.stringify(data)); return data;历史月份早于当前月的数据理论上不会再变TTL 可以设长一点比如 24 小时。当月数据因为有新账单进来要么设短 TTL比如 5 分钟要么在写账单的时候主动删缓存。我倾向后者写操作后主动del对应 key读的时候如果 miss 就重建。这种做法一致性更好代价是每次写账单多一次 Redis 调用可以放进队列异步做。注意别在finally里无脑删缓存。我见过一个实现是在账单写入的finally块里删 key结果事务回滚了缓存也被删了白白增加一次重建开销。删缓存应该在事务提交之后做。4.2 大表场景下的预聚合思路当单表数据到千万级哪怕是走索引的月度聚合也会变慢尤其是运营后台要查全平台所有用户本月统计这种场景。这时候就得考虑预聚合。我的做法是建一张bill_month_stat汇总表用 eggjs 的定时任务app/schedule/在每天凌晨跑一次把前一天的数据增量累加进去// app/schedule/bill_stat.js module.exports { schedule: { cron: 0 30 2 * * *, // 每天 02:30 type: worker, // 单进程执行避免多 worker 重复跑 immediate: false, }, async task(ctx) { await ctx.service.bill.rebuildYesterdayStat(); }, };type: worker这个配置很重要。eggjs 默认是多进程worker模式type: all会让每个 worker 都跑一遍汇总数据直接翻倍。这个坑我踩过一次凌晨三点数据库 CPU 飙到 100%第二天看汇总表的数据是全平台的两倍。增量累加的逻辑要小心幂等。我的写法是先删后插按天维度覆盖async rebuildYesterdayStat() { const d dayjs().subtract(1, day).format(YYYY-MM-DD); const rows await this.app.model.Bill.findAll({ where: { ... }, group: [user_id] }); // 先删除当天已有汇总再批量插入 await this.app.model.BillMonthStat.destroy({ where: { stat_date: d } }); await this.app.model.BillMonthStat.bulkCreate(rows); }destroybulkCreate要放在同一个事务里否则任务中途失败会出现半天数据。bulkCreate记得分批一次塞几千条会超max_allowed_packet。5. 排错实录与常见问题速查5.1 数据对不上我是这么一步步查的统计接口的数字和明细列表加起来的数字不一样这是我这几年被问得最多的问题。排查我有一套固定顺序通常十分钟内能定位。第一步看明细列表和统计的 WHERE 条件是否完全一致。九成的问题出在这儿。明细列表可能过滤了status ! 0统计写的是status 1中间那部分退款中的账单就成了差额来源。把两边 SQL 都打印出来逐字对比。第二步看时区。在 MySQL 里执行SELECT timezone, NOW()和 Node 进程的new Date().toString()对一下。如果差了 8 小时那就是连接配置没写timezone: 08:00或者dateStrings没开。第三步看金额精度。明细是DECIMAL统计是BIGINT或者中间某处被parseInt截断过都会导致差几分钱。这种差额通常很小但一定要修因为财务对账是按分算的。第四步看是否重复统计。有些业务会在账单表里存多种类型的记录比如支付成功和结算完成各一条统计时没区分record_type金额就会翻倍。这种问题表现为刚好是两倍很好识别。5.2 常见问题速查表现象大概率原因处理方式统计金额比明细少两边 status 过滤条件不一致统一口径把条件抽成常量月底最后一天数据丢失用了BETWEEN且时间有毫秒精度改成 start AND end整体偏移 8 小时连接未配 timezone或dateStrings未开在 sequelize 配置里补齐趋势图横轴缺日期GROUP BY不返回空日期Service 层补零接口偶发 500报 BigInt 序列化错误BIGINT 被读成 JS BigInt开bigNumberStrings: true定时任务汇总数据翻倍type: all导致多 worker 重复执行改成type: worker查询很慢但索引都建了WHERE里对字段用了函数索引失效把函数挪到SELECT或GROUP BY缓存和库不一致写操作没删缓存或删在事务回滚后事务提交后再删 key5.3 我自己踩过的几个坑坑一ctx.query的类型全是字符串。userId传过来是12345如果你的表里user_id是 BIGINTMySQL 会做隐式类型转换索引照样能用但如果userId传的是12345abcMySQL 会把它转成12345然后匹配到错误的用户。这个行为非常隐蔽所以我在 Controller 里一定会做一次Number()转换和Number.isSafeInteger校验。坑二别在 Service 里用ctx.throw抛业务错误。Service 应该是纯数据逻辑错误处理交给 Controller 或者全局onerror配置。我见过 Service 里到处ctx.throw(400, 参数错误)结果这个 Service 被定时任务复用时ctx是 mock 出来的抛出的错误直接导致任务崩溃且没有任何日志。坑三GROUP BY配合ONLY_FULL_GROUP_BY。MySQL 5.7 之后默认开启ONLY_FULL_GROUP_BYSELECT里出现的非聚合字段必须在GROUP BY里。我写SELECT biz_type, merchant_id, SUM(amount) ... GROUP BY biz_type这种就想拿每个类目下金额最大的商户直接被数据库拒绝。正确做法是用窗口函数或者分两次查询。坑四测试数据里没有空月份。本地开发时数据都是我造的每个月都有几十条接口跑得好好的。上线之后遇到一个只用了一个月就流失的用户查 12 月数据SUM返回NULL前端页面白屏。所以我现在写统计接口第一件测试用例就是零数据然后是单条数据、跨月边界数据、退款单。5.4 上线前我会过一遍的清单参数校验是否覆盖了month格式、userId类型、非法字符空数据场景是否返回 0 而不是null金额是否全程以分参与运算只在出口转元时间区间是否用半开区间是否有跨年12 月的测试用例索引是否命中EXPLAIN是否走了idx_user_settle缓存 key 是否有用户隔离别写成全局 key定时任务是否type: worker统计口径是否在接口文档里写清楚是否在响应里回传statRule我个人在实际操作中的体会是月度统计接口的代码量其实不大真正花时间的是和业务方把口径聊清楚以及把边界场景一个个测出来。写 SQL 那部分最多占三成精力剩下七成都是在回答这笔退款算哪个月用户 31 号 23:59 的单子算不算这个月负数净额怎么展示这类问题。所以我的建议是动手写代码之前先拿一份真实账单数据用 Excel 手工算一遍正确答案把这个结果当成测试基线后面所有改动都拿它来对比写十行断言都管用。这个接口后续还能往两个方向扩一是支持自定义时间段传入startDate/endDate替代month二是支持多币种把currency加进WHERE和GROUP BY。第一个改动很小把_monthRange换成通用区间解析就行第二个要动表结构建议一开始就把currency字段留上哪怕现在只有人民币省得以后加字段还要写数据迁移脚本。