🐟 鱼蛋看板与小账本 API 使用教程

🐟 鱼蛋看板与小账本 API 使用教程

2026-08-21
17 分钟阅读
#技术笔记 #鱼蛋小账本 #API 教程

摘要

鱼蛋看板与小账本 API 完整文档,包含 API Key 鉴权、疫苗和体重写入,以及账本增删改查的请求示例与响应结构。

概览

鱼蛋站点提供 10 个主要 REST API,用于记录疫苗、体重和家庭收支。所有接口返回 JSON;看板接口统一使用 API Key,账本写入接口接受 API Key 或授权的 GitHub 登录令牌。

接口方法用途认证
/api/listGET分页查询交易记录
/api/monthlyGET月度聚合数据
/api/dailyGET某日支出明细
/api/addPOST新增交易
/api/editPATCH修改交易
/api/deleteDELETE删除交易
/api/yudanGET读取疫苗和体重记录
/api/yudan/vaccinesGET读取标准疫苗目录与计划 ID
/api/yudan/weightPOST新增或更新体重
/api/yudan/vaccinePOST登记实际接种日期

认证

调用看板接口,或通过 API 写入账本时,需要在请求头中携带 API Key:

Authorization: Bearer <API_KEY>

API Key 已配置在 Vercel 的 API_KEY 环境变量中。不要把真实 Key 写进网页、前端 JavaScript、GitHub 仓库或聊天记录;应把它保存在调用方的环境变量或密钥管理器中。

在终端中可以临时设置一个只在当前会话生效的变量:

# macOS / Linux
export YUDAN_API_KEY='你的 API Key'
# Windows PowerShell
$env:YUDAN_API_KEY = '你的 API Key'

账本的查询接口 /api/list/api/monthly/api/daily 仍可公开读取。


疫苗与体重看板 API

看板 API 的正式地址统一以 https://cost.ykn.cm 开头。日期必须使用 YYYY-MM-DD 格式,并且不能晚于当天。

读取当前看板记录

GET /api/yudan
curl "https://cost.ykn.cm/api/yudan" \
  -H "Authorization: Bearer $YUDAN_API_KEY"

成功时返回出生日期、已完成的疫苗记录、体重记录和最后更新时间。字段结构如下,不包含任何真实记录:

interface DashboardResponse {
  success: true;
  data: {
    birthday: string | null;
    vaccine_records: Array<{
      id: string;
      vaccine: string;
      dose: string;
      ageLabel: string;
      doneDate: string;
    }>;
    weight_records: Array<{
      id: string;
      date: string;
      weight: number;
    }>;
    updated_at: string;
  };
}

写入体重

POST /api/yudan/weight
字段类型必填说明
datestring测量日期,格式为 YYYY-MM-DD
weightnumber体重,单位为 kg,允许范围 0.1200
MEASURED_DATE='<YYYY-MM-DD>'
WEIGHT_KG='<KG>'

curl -X POST "https://cost.ykn.cm/api/yudan/weight" \
  -H "Authorization: Bearer $YUDAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"date\":\"$MEASURED_DATE\",\"weight\":$WEIGHT_KG}"

成功时,data.record 返回保存后的记录,data.created 表示这次是新建还是更新。

同一天再次调用会更新原记录,而不是产生重复数据。此时响应中的 createdfalse

写入实际接种日期

POST /api/yudan/vaccine
字段类型必填说明
plan_idstring推荐标准疫苗目录中的稳定 ID,例如 schedule-001
vaccinestring条件必填未提供 plan_id 时用于自动查询疫苗
dosestring配合 vaccine 缩小到具体剂次
actual_datestring实际接种日期,格式为 YYYY-MM-DD

先读取数据库中的标准目录:

curl "https://cost.ykn.cm/api/yudan/vaccines" \
  -H "Authorization: Bearer $YUDAN_API_KEY"

目录会返回每一项的 plan_id、标准名称、剂次、年龄标签、建议日期、现有实际日期,以及 regionschedule_versionpreventsaudienceschedule_notesource。当前目录采用浙江省杭州市 2026-08 清单,共 46 个稳定计划项。推荐直接使用返回的 plan_id 写入:

VACCINATION_DATE='<YYYY-MM-DD>'

curl -X POST "https://cost.ykn.cm/api/yudan/vaccine" \
  -H "Authorization: Bearer $YUDAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"plan_id\":\"schedule-001\",\"actual_date\":\"$VACCINATION_DATE\"}"

成功时,data.record 返回保存后的接种记录,data.created 表示这次是新建还是更新。

不方便提前读取目录时,也可以传 vaccinedose。服务端会查询标准名称及别名,例如 乙肝HepBA群流脑疫苗 都会先归一到数据库中的正式名称,再自动补齐剂次和年龄标签,不再要求调用方填写 age_label。如果匹配到多个项目,接口返回 409candidates,调用方从中选择 plan_id 后重试;系统不会猜测或创建重复标签。同一 plan_id 再次写入会更新实际日期,created 返回 false

在 JavaScript 或自动化工具中调用

服务端脚本、定时任务、快捷指令或自动化平台都可以调用这组接口。下面是一个 Node.js 示例:

const baseUrl = 'https://cost.ykn.cm';
const apiKey = process.env.YUDAN_API_KEY;

if (!apiKey) throw new Error('缺少 YUDAN_API_KEY');

async function callYudanApi(path, body) {
  const response = await fetch(`${baseUrl}${path}`, {
    method: body ? 'POST' : 'GET',
    headers: {
      Authorization: `Bearer ${apiKey}`,
      ...(body ? { 'Content-Type': 'application/json' } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });

  const result = await response.json();
  if (!response.ok) throw new Error(result.error || `请求失败:${response.status}`);
  return result.data;
}

await callYudanApi('/api/yudan/weight', {
  date: process.env.MEASURED_DATE,
  weight: Number(process.env.WEIGHT_KG),
});

await callYudanApi('/api/yudan/vaccine', {
  plan_id: 'schedule-001',
  actual_date: process.env.VACCINATION_DATE,
});

看板 API 常见错误

状态码原因
400JSON、日期、体重或疫苗字段不符合要求
401API Key 无效或没有提供
404标准疫苗目录中没有匹配项目
409匹配到多个项目,需要从候选项选择 plan_id
500数据库连接或服务端更新失败

API Key 只决定是否允许 API 调用;真正的数据写入仍由服务端完成,Supabase Secret Key 不会发送给调用方。


1. 查询交易列表(游标分页)

GET /api/list

请求参数(Query)

参数类型必填说明默认值
limitnumber每页条数,最大 10030
cursorstring上一页最后一条的 created_at(ISO 8601),首次加载不传-
typestring筛选类型:expenseincome不筛选
categorystring筛选分类名称不筛选

请求示例

# 首页加载(无 cursor)
curl "https://cost.ykn.cm/api/list?limit=30"

# 加载下一页
curl "https://cost.ykn.cm/api/list?limit=30&cursor=2026-05-01T12:00:00Z"

# 只查支出
curl "https://cost.ykn.cm/api/list?type=expense&limit=50"

# 按分类筛选
curl "https://cost.ykn.cm/api/list?category=喂养用品"

响应结构

{
  "success": true,
  "data": [
    {
      "id": "a1b2c3d4-...",
      "amount": 120.5,
      "category": "喂养用品",
      "note": "奶粉",
      "type": "expense",
      "transaction_time": "2026-05-01T10:30:00Z",
      "created_at": "2026-05-01T10:30:05Z"
    }
  ],
  "nextCursor": "2026-04-28T08:30:00Z",
  "hasMore": true
}
字段说明
data当页交易记录数组
nextCursor下一页的游标值,传入下次请求的 cursor 参数。无更多数据时为 null
hasMore是否还有更多数据

分页流程

第 1 页: GET /api/list?limit=30
         → nextCursor: "2026-04-28T08:30:00Z", hasMore: true

第 2 页: GET /api/list?limit=30&cursor=2026-04-28T08:30:00Z
         → nextCursor: "2026-04-15T14:20:00Z", hasMore: true

第 3 页: GET /api/list?limit=30&cursor=2026-04-15T14:20:00Z
         → nextCursor: null, hasMore: false  ← 没有更多了

为什么用游标分页

数据库索引 idx_transactions_created_at ON transactions (created_at DESC) 直接支持此查询。选择游标而非 offset 分页,是因为 Telegram Bot 会异步写入新记录,offset 分页会出现数据偏移(重复或遗漏),游标锚定在 created_at 上,不受新插入影响。


2. 月度聚合数据

GET /api/monthly

一次请求返回当月所有预计算数据,Dashboard 四个组件(汇总卡片、趋势图、分类饼图、日历热力图)可直接使用,无需前端二次计算。

请求参数(Query)

参数类型必填说明
yearnumber年份,如 2026
monthnumber月份,1-12

请求示例

curl "https://cost.ykn.cm/api/monthly?year=2026&month=5"

响应结构

{
  "success": true,
  "data": {
    "year": 2026,
    "month": 5,
    "totalExpense": 3280.50,
    "transactionCount": 42,
    "dailyExpenses": [
      { "date": "2026-05-01", "amount": 120 },
      { "date": "2026-05-02", "amount": 0 },
      { "date": "2026-05-03", "amount": 85 }
    ],
    "categoryBreakdown": [
      { "category": "喂养用品", "amount": 1200, "count": 8 },
      { "category": "辅食零食", "amount": 800, "count": 12 }
    ],
    "calendarData": { "1": 120, "3": 85, "5": 200 },
    "prevMonthExpense": 2950.00,
    "allTimeExpense": 28500.00,
    "lastTransaction": {
      "amount": 45,
      "category": "辅食零食",
      "note": "酸奶",
      "transaction_time": "2026-05-03T09:00:00Z"
    }
  }
}

字段说明

字段类型用途
totalExpensenumber当月总支出
transactionCountnumber当月交易笔数
dailyExpensesarray每日支出金额,按日期升序,无支出的日期金额为 0。直接喂给趋势图
categoryBreakdownarray分类汇总,按金额降序排列。直接喂给饼图
calendarDataobject日期(几号)到金额的 map,如 { "1": 120, "3": 85 }。直接喂给日历热力图
prevMonthExpensenumber上月总支出,用于计算环比变化
allTimeExpensenumber历史全部总支出
lastTransactionobject/null最近一笔支出记录,无记录时为 null

内部实现

服务端并行执行三条 Supabase 查询(当月、上月、全部),然后在内存中聚合:

┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐
│  当月交易记录    │  │  上月交易记录    │  │  全部交易记录    │
└────────┬────────┘  └────────┬────────┘  └────────┬────────┘
         │                    │                    │
         ▼                    ▼                    ▼
   dailyExpenses        prevMonthExpense     allTimeExpense
   categoryBreakdown
   calendarData
   lastTransaction

3. 日支出明细

GET /api/daily

返回某一天的全部支出记录,用于日历热力图点击某天后的详情弹窗。

请求参数(Query)

参数类型必填说明
yearnumber年份
monthnumber月份,1-12
daynumber日期,1-31

请求示例

curl "https://cost.ykn.cm/api/daily?year=2026&month=5&day=1"

响应结构

{
  "success": true,
  "data": [
    {
      "id": "a1b2c3d4-...",
      "amount": 120,
      "category": "喂养用品",
      "note": "奶粉",
      "type": "expense",
      "transaction_time": "2026-05-01T10:30:00Z",
      "created_at": "2026-05-01T10:30:05Z"
    }
  ]
}

只返回 type=expense 的记录,按 created_at 降序排列。


4. 新增交易

POST /api/add

请求头

Content-Type: application/json
Authorization: Bearer <API_KEY>

请求体

字段类型必填说明
amountnumber金额
typestringexpense(支出)或 income(收入)
categorystring分类名称
notestring备注
transaction_timestring交易时间(ISO 8601),不传则使用服务端当前时间

请求示例

curl -X POST "https://cost.ykn.cm/api/add" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $YUDAN_API_KEY" \
  -d '{
    "amount": 120.5,
    "type": "expense",
    "category": "喂养用品",
    "note": "奶粉",
    "transaction_time": "2026-05-01T10:30:00Z"
  }'

响应结构

{
  "success": true,
  "data": {
    "id": "a1b2c3d4-...",
    "amount": 120.5,
    "category": "喂养用品",
    "note": "奶粉",
    "type": "expense",
    "transaction_time": "2026-05-01T10:30:00Z",
    "created_at": "2026-05-01T10:30:05Z"
  }
}

错误响应

状态码原因
400缺少 amount 或 type、type 值非法、amount 格式错误
401API Key 无效或未提供
500服务端错误

5. 修改交易

PATCH /api/edit

请求头

Content-Type: application/json
Authorization: Bearer <API_KEY>

请求体

字段类型必填说明
idstring交易记录 UUID
amountnumber新金额
typestring新类型
categorystring新分类
notestring新备注
transaction_timestring新交易时间

只需传入要修改的字段,未传的字段保持不变。

请求示例

curl -X PATCH "https://cost.ykn.cm/api/edit" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $YUDAN_API_KEY" \
  -d '{
    "id": "a1b2c3d4-...",
    "amount": 150,
    "note": "更新备注"
  }'

响应结构

{
  "success": true,
  "data": {
    "id": "a1b2c3d4-...",
    "amount": 150,
    "category": "喂养用品",
    "note": "更新备注",
    "type": "expense",
    "transaction_time": "2026-05-01T10:30:00Z",
    "created_at": "2026-05-01T10:30:05Z"
  }
}

错误响应

状态码原因
400缺少 id、type 值非法、amount 格式错误
401API Key 无效或未提供
500服务端错误

6. 删除交易

DELETE /api/delete

请求头

Content-Type: application/json
Authorization: Bearer <API_KEY>

请求体

字段类型必填说明
idstring交易记录 UUID

请求示例

curl -X DELETE "https://cost.ykn.cm/api/delete" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $YUDAN_API_KEY" \
  -d '{ "id": "a1b2c3d4-..." }'

响应结构

{
  "success": true
}

错误响应

状态码原因
400缺少 id
401API Key 无效或未提供
500服务端错误

完整使用流程

场景:构建一个服务端记账工具

下面涉及 API Key 的 JavaScript 必须运行在服务端脚本或可信自动化环境中,不能打包进浏览器页面。

第 1 步:加载首页数据

const apiBaseUrl = 'https://cost.ykn.cm';
const apiKey = process.env.YUDAN_API_KEY;

if (!apiKey) throw new Error('缺少 YUDAN_API_KEY');

// 概览 Tab:请求月度聚合
const monthly = await fetch(`${apiBaseUrl}/api/monthly?year=2026&month=5`).then(r => r.json());
console.log(`本月支出: ¥${monthly.data.totalExpense}`);
console.log(`上月支出: ¥${monthly.data.prevMonthExpense}`);
console.log(`交易笔数: ${monthly.data.transactionCount}`);

// 明细 Tab:请求第一页
const list = await fetch(`${apiBaseUrl}/api/list?limit=30`).then(r => r.json());
console.log(`加载了 ${list.data.length} 条记录`);
console.log(`还有更多: ${list.hasMore}`);

第 2 步:加载更多明细

async function loadMore() {
  if (!list.hasMore) return;
  const next = await fetch(`${apiBaseUrl}/api/list?limit=30&cursor=${list.nextCursor}`).then(r => r.json());
  list.data.push(...next.data);
  list.nextCursor = next.nextCursor;
  list.hasMore = next.hasMore;
}

第 3 步:新增一笔记录

const res = await fetch(`${apiBaseUrl}/api/add`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${apiKey}`,
  },
  body: JSON.stringify({
    amount: 45,
    type: 'expense',
    category: '辅食零食',
    note: '酸奶',
  }),
}).then(r => r.json());
console.log(`新增成功,ID: ${res.data.id}`);

第 4 步:修改记录

await fetch(`${apiBaseUrl}/api/edit`, {
  method: 'PATCH',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${apiKey}`,
  },
  body: JSON.stringify({
    id: 'a1b2c3d4-...',
    amount: 50,
  }),
});

第 5 步:删除记录

await fetch(`${apiBaseUrl}/api/delete`, {
  method: 'DELETE',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${apiKey}`,
  },
  body: JSON.stringify({ id: 'a1b2c3d4-...' }),
});

第 6 步:查看某日详情

const daily = await fetch(`${apiBaseUrl}/api/daily?year=2026&month=5&day=1`).then(r => r.json());
daily.data.forEach(t => {
  console.log(`${t.category}: ¥${t.amount} - ${t.note}`);
});

数据库结构

所有接口操作同一张 transactions 表:

CREATE TABLE transactions (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  amount DECIMAL NOT NULL,
  category TEXT,
  note TEXT,
  type TEXT CHECK (type IN ('expense', 'income')) NOT NULL,
  transaction_time TIMESTAMP WITH TIME ZONE,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL
);

CREATE INDEX idx_transactions_created_at ON transactions (created_at DESC);
  • id — 自动生成的 UUID 主键
  • amount — 金额,DECIMAL 类型
  • category — 分类名称,可为空
  • note — 备注,可为空
  • typeexpense(支出)或 income(收入)
  • transaction_time — 用户指定的交易时间,可为空(为空时用 created_at 兜底)
  • created_at — 记录创建时间,自动填充,分页索引字段

TypeScript 类型定义

interface Transaction {
  id: string;
  amount: number;
  note: string;
  category: string;
  type: 'expense' | 'income';
  transaction_time?: string;
  created_at: string;
}

interface DailyExpense {
  date: string;    // "2026-05-01"
  amount: number;
}

interface CategorySummary {
  category: string;
  amount: number;
  count: number;
}

interface MonthlyData {
  year: number;
  month: number;
  totalExpense: number;
  transactionCount: number;
  dailyExpenses: DailyExpense[];
  categoryBreakdown: CategorySummary[];
  calendarData: Record<number, number>;  // { 1: 120, 3: 85 }
  prevMonthExpense: number;
  allTimeExpense: number;
  lastTransaction: {
    amount: number;
    category: string;
    note: string;
    transaction_time: string;
  } | null;
}

分享文章