Files
oil-price-monitoring/docs/43.记账API详细设计.md
maxf 28fcfc6aec ## 1️⃣ 文档与代码一致性 ✅(修复 4 处滞后)
**发现并已修复的文档滞后**(后期代码先于文档,审计时补齐):

| 滞后点 | 修复 |
|---|---|
| `24.附录` users 表缺 `wechat_openid` 列(二期微信登录加的) | ✅ 已补 |
| `24.附录` 缺 `refuel_records` 表(三期记账) | ✅ 已补 |
| `24.附录` 错误码缺 `1012/1013`(三期) | ✅ 已补 |
| `34.附录` 小程序页面目录缺 `calc/refuels`(三期) | ✅ 已补 |

**核对一致项**:
- API 路由 **A1-A23 + `/api/auth/wechat`** 全部注册,与 22/33/43 文档逐项对应
- `schema.sql` 11 表 = 24 附录(10 + refuel)+ 45 附录
- 小程序 8 页文件齐全(js/wxml/wxss/json × 8),`refuels.wxml` 数据绑定与 js 匹配
- H5 8 页面全部可访问

## 2️⃣ 后期变更对前期的影响 ✅ 无破坏

综合回归 **20 项全 PASS**:
- **一期**:账号密码登录 / Cookie 会话 / dashboard / provinces(31省) / history ✅
- **二期**:Bearer token 认证(与 Cookie **双通道共存**,互不冲突)✅
- **三期**:refuel 首条保存 / 区间油耗 8.25 / 统计 / 预填 ✅
- **前端**:9 个资源(8 页 + 静态 JS)全部 200 ✅

关键:二期的 `users` 加列是**幂等增量迁移**(ALTER),未动一期任何表结构;一期 API 与前端全部正常。

## 3️⃣ 整体可行性 ✅ 在可控范围

| 维度 | 状态 |
|---|---|
| 构建/静态检查 | `go build / vet` 全绿 |
| 单元测试 | 6 包 17 单测全过(auth/crawler/cron/db/push/service) |
| 规模 | 11 表 · 23+ 接口 · 8 H5 页面 · 8 小程序页面 · 17 份文档 · ~90 代码文件 |
| 三阶段功能 | 监控预测提醒 → 微信小程序 → 加油记账/真实油耗,全部落地 |

**非阻塞小项**(可后续优化,不影响运行):
1. `refuels.wxml` 第 11 行 `wx:for` 与 `wx:if` 同节点——功能正常,写法可规范
2. api 包 handler 层无独立单测——由端到端回归覆盖(service 层已有单测)

---

**结论:项目整体健康、文档与代码对齐、三阶段功能完整可用。** 4 处文档滞后已修复,无功能性问题。可以进入部署/上线阶段。
2026-09-25 16:56:07 +08:00

11 KiB
Raw Permalink Blame History

⛽ 三期 · 记账功能 — API 详细设计

上游:docs/40.记账概要设计.md、docs/24.附录.md(通用规则/错误码) 编号:A18-A23(延续一期 A1-A17) 全部 🔒登录,按 user_id 归属;vehicle_id 必须属于当前用户,否则 404。


A18 · GET /api/refuels(🔒登录)

功能:查询某辆车的加油记录列表(倒序,含油耗计算字段)。

请求参数

参数 位置 类型 必填 说明
vehicle_id query int 是 车辆 id(须属于当前用户)
limit query int 否 返回条数,默认 50,最大 200
offset query int 否 分页偏移,默认 0

响应(HTTP 200)

{
  "code": 0, "message": "ok",
  "data": {
    "vehicle": { "id": 1, "name": "我的迈腾", "fuel_type": "95" },
    "records": [
      { "id": 3, "refuel_at": "2026-09-24 18:00:00", "odometer": 12800, "price_per_liter": 8.29,
        "amount": 300, "discount": 0, "net_amount": 300, "liters": 36.19, "fuel_type": "95",
        "full_tank": true, "note": "", "calc_distance_km": 280, "calc_l_per_100km": 12.9,
        "calc_yuan_per_km": 1.07, "anomaly": null },
      { "id": 2, "refuel_at": "2026-09-10 08:30:00", "odometer": 12520, "price_per_liter": 8.25,
        "amount": 320, "discount": 20, "net_amount": 300, "liters": 36.36, "calc_l_per_100km": 7.3,
        "calc_yuan_per_km": 0.58, "anomaly": null }
    ]
  }
}

响应字段说明

字段 类型 说明
data.records[].id int 记录 id
data.records[].refuel_at string 加油时间
data.records[].odometer float 码表里程(km)
data.records[].price_per_liter float 油价(元/升)
data.records[].amount / discount / net_amount float 支付 / 优惠 / 实付金额(元)
data.records[].liters float 加油量(升)
data.records[].fuel_type string 油号
data.records[].full_tank bool 是否加满
data.records[].calc_distance_km float | null 区间里程(距上次)
data.records[].calc_l_per_100km float | null 区间油耗 L/100km
data.records[].calc_yuan_per_km float | null 区间成本 元/km
data.records[].anomaly string | null odometer(里程回退)/ null

处理逻辑:校验 vehicle_id 归属 → 按 refuel_at DESC, id DESC 分页查询。

DB 操作(SQL)

SELECT v.id, v.name, v.fuel_type FROM vehicles v WHERE v.id = ? AND v.user_id = ?;  -- 不存在 → 404
SELECT id, refuel_at, odometer, price_per_liter, amount, discount, net_amount,
       liters, fuel_type, full_tank, note, calc_distance_km, calc_l_per_100km,
       calc_yuan_per_km, anomaly
FROM refuel_records WHERE vehicle_id = ?
ORDER BY refuel_at DESC, id DESC LIMIT ? OFFSET ?;

A19 · POST /api/refuels(🔒登录)

功能:新增加油记录。计算油耗(与上次差值)+ 漏记检测。

请求参数

参数 位置 类型 必填 说明
vehicle_id body int 是 车辆 id
odometer body float 是 当前码表里程(km)
amount body float 是 支付金额(元,>0)
price_per_liter body float 是 油价(元/升,>0)
discount body float 否 优惠金额,默认 0
refuel_at body string 否 加油时间,默认当前
fuel_type body string 否 油号,缺省取该车油号
full_tank body bool 否 本次是否加满(仅标签展示,不参与计算),默认 true
note body string 否 备注

响应(HTTP 200)

{
  "code": 0, "message": "ok",
  "data": {
    "record": { "id": 4, "refuel_at": "2026-09-24 18:00:00", "odometer": 12800,
                "price_per_liter": 8.29, "amount": 300, "discount": 0, "net_amount": 300,
                "liters": 36.19, "calc_distance_km": 280, "calc_l_per_100km": 12.9,
                "calc_yuan_per_km": 1.07, "anomaly": null },
    "possible_missing": true
  }
}

响应字段说明

字段 类型 说明
data.record object 新记录(含油耗计算字段)
data.possible_missing bool 异常检测:true 提示"耗油量异常,是否漏记?"

处理逻辑

  1. 校验参数与 vehicle_id 归属。
  2. net = amount - discount;liters = net / price(保留 2 位)。
  3. 查该车最近一条 refuel_at < 本次(或 odometer < 本次)的有效记录作基准。
  4. 若基准存在:
    • distance = odometer_now - last_odometer
    • distance > 0 → 计算 calc_l_per_100km / calc_yuan_per_km
    • distance <= 0 → anomaly=odometer(里程回退,标黄提示)
    • 无基准(首条)→ 油耗 NULL
    • 注:full_tank 仅作标签展示,不参与油耗计算。
  5. 异常检测(相比该车历史平均;无历史平均则跳过):
    • 本次 price > 历史平均 price × 2
    • 或 calc_l_per_100km > 历史平均 L/100km × 2 → possible_missing=true("耗油量异常,是否漏记?")。
  6. 入库;返回记录 + possible_missing。

DB 操作(SQL)

-- 上次有效记录(该车,时间最接近本次的)
SELECT odometer, refuel_at FROM refuel_records
WHERE vehicle_id = ? AND (refuel_at < ? OR (refuel_at = ? AND id < ?))
ORDER BY refuel_at DESC, id DESC LIMIT 1;

INSERT INTO refuel_records(user_id, vehicle_id, refuel_at, odometer, price_per_liter,
    amount, discount, net_amount, liters, fuel_type, full_tank, note,
    calc_distance_km, calc_l_per_100km, calc_yuan_per_km, anomaly, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime'));

A20 · PUT /api/refuels/:id(🔒登录)

功能:修正加油记录。级联重算本条及受影响的下一条。

请求参数

参数 位置 类型 必填 说明
id path int 是 记录 id(须属于当前用户)
Body body object 是 全量字段,同 A19 请求参数

响应

{ "code": 0, "message": "ok",
  "data": { "record": { "...": "更新后的记录" }, "recalc_ids": [3] } }

响应字段说明

字段 类型 说明
data.record object 更新后的记录
data.recalc_ids array 本次级联重算影响的记录 id 列表

处理逻辑

  1. 校验记录属于当前用户(id + user_id)。
  2. 更新字段;重算本条(与上一条)。
  3. 若其后存在下一条,重算下一条(本条成为其基准)。
  4. 返回更新后的记录与 recalc_ids。

DB 操作(SQL)

SELECT id FROM refuel_records WHERE id = ? AND user_id = ?;  -- 不存在 → 404
UPDATE refuel_records SET odometer=?, price_per_liter=?, amount=?, discount=?,
    net_amount=?, liters=?, fuel_type=?, full_tank=?, note=?, updated_at=datetime('now','localtime')
WHERE id = ? AND user_id = ?;
-- 级联重算:见 docs/45.记账附录.md 第三节(同一事务)

A21 · DELETE /api/refuels/:id(🔒登录)

功能:删除加油记录。级联重算其后第一条(基准变为被删记录的上一条)。

处理逻辑:校验归属 → 删除 → 下一条的基准改为被删记录的前一条 → 重算下一条。

响应:{"code":0,"message":"ok"}

DB 操作(SQL)

DELETE FROM refuel_records WHERE id = ? AND user_id = ?;  -- 0 行 → 404
-- 级联重算下一条:见 docs/45.记账附录.md 第三节

A22 · GET /api/refuels/prefill?vehicle_id=X(🔒登录)

功能:"记一笔"页回显:当前油价 + 上次码表里程(用户可修改)。

请求参数

参数 位置 类型 必填 说明
vehicle_id query int 是 车辆 id

响应(HTTP 200)

{
  "code": 0, "message": "ok",
  "data": {
    "vehicle": { "id": 1, "name": "我的迈腾", "province": "北京", "fuel_type": "95" },
    "current_price": 8.29,          // 该车省份最新 95# 价;无则 null(用户填)
    "last_odometer": 12520,         // 上次记录里程;无则 null
    "last_refuel_at": "2026-09-10 08:30:00",
    "avg_l_per_100km": 7.3          // 该车历史平均油耗(参考展示);无则 null
  }
}

响应字段说明

字段 类型 说明
data.current_price float | null 回显油价(该车省份该油号最新价)
data.last_odometer float | null 回显上次里程(最近一条记录)
data.last_refuel_at string | null 上次加油时间
data.avg_l_per_100km float | null 该车平均油耗(参考)

DB 操作(SQL)

-- 当前油价(该车省份 + 该车油号)
SELECT CASE WHEN v.fuel_type='0' THEN pp.price_0
            WHEN v.fuel_type='98' THEN pp.price_98
            WHEN v.fuel_type='95' THEN pp.price_95
            ELSE pp.price_92 END
FROM province_prices pp
JOIN (SELECT province, MAX(id) mid FROM province_prices GROUP BY province) t ON pp.id=t.mid
JOIN vehicles v ON v.province = pp.province
WHERE v.id = ? AND v.user_id = ?;

-- 上次里程/时间
SELECT odometer, refuel_at FROM refuel_records
WHERE vehicle_id = ? ORDER BY refuel_at DESC, id DESC LIMIT 1;

A23 · GET /api/refuels/stats?vehicle_id=X(🔒登录)

功能:油耗统计(折线图数据 + 汇总)。

请求参数

参数 位置 类型 必填 说明
vehicle_id query int 是 车辆 id

响应(HTTP 200)

{
  "code": 0, "message": "ok",
  "data": {
    "summary": { "total_records": 12, "total_amount": 3600, "total_liters": 450,
                 "avg_l_per_100km": 8.1, "avg_yuan_per_km": 0.55 },
    "trends": {
      "dates": ["2026-09-10", "2026-09-24", "2026-10-08"],
      "l_per_100km": [8.2, 7.9, null],
      "yuan_per_km": [0.58, 0.55, null],
      "price": [8.25, 8.29, 8.29],
      "cumulative_amount": [320, 650, 950]
    }
  }
}

响应字段说明

字段 类型 说明
data.summary.total_records int 记录条数(含无油耗的)
data.summary.total_amount / total_liters float 累计实付金额 / 累计加油量
data.summary.avg_l_per_100km float | null 平均油耗(有 calc 的记录求均)
data.summary.avg_yuan_per_km float | null 平均元/km
data.trends.dates array 日期序列(按加油时间升序)
data.trends.l_per_100km array 对应 L/100km(无油耗为 null)
data.trends.yuan_per_km array 对应 元/km
data.trends.price array 对应油价
data.trends.cumulative_amount array 累计实付金额(面积图)

处理逻辑:取该车全部记录(升序),聚合汇总 + 折线序列。