# 个人 API 服务 · 接口文档

> Serverless 函数集合。上传月度销售 Excel（xlsx）→ 返回 JSON 统计结果。
> 全程 HTTPS、无状态、可自动扩缩容。除 `/api/health` 外均需签名认证。
> Base URL：`https://apis.gankun.cn.lu`。

## 接口列表

| 方法 | 路径 | 认证 | 说明 |
| --- | --- | --- | --- |
| POST | `/api/analyze` | 需签名认证 | 上传 xlsx（base64），返回按月份拆分的销售统计（MonthlySalesStats） |
| GET | `/api/health` | 公开 | 健康检查，返回服务状态与接口列表 |

## 认证与签名（HMAC-SHA256）

受保护请求需携带三个请求头：

| 请求头 | 说明 |
| --- | --- |
| `x-api-key` | API Key，客户端标识（可公开；凭据由服务提供方签发，附带的 `scripts/gen-key.mjs` 可自助生成） |
| `x-timestamp` | 请求时间戳（秒），服务端校验 5 分钟窗口防重放 |
| `x-signature` | HMAC-SHA256 签名（十六进制小写） |

签名计算（对**实际发送的请求体原文**，即加密前/加密后的最终 body）：

```
bodyHash   = sha256Hex(请求体原文)
signature  = HMAC-SHA256(secret, `${timestamp}:${bodyHash}`).hex()
```

Secret 仅为客户端与服务端共有，请勿公开。传输层由 HTTPS 保证；签名保证完整性 + 防重放。

### 可选请求体加密（AES-256-GCM）

设置请求头 `x-encrypted: 1` 后，请求体为 AES-GCM 密文，格式 `base64(iv).base64(tag).base64(ciphertext)`。
密钥由 `sha256(secret + ":" + apiKey)` 派生（32 字节）。开启时先加密、再对密文计算签名。

## POST /api/analyze

### 请求体（JSON）

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `file` | string | 是 | xlsx 文件的 base64 内容（原始二进制，非 Data URL） |
| `filename` | string | 否 | 原始文件名，仅用于日志/溯源 |

```json
{
  "file": "<xlsx 文件的 base64 内容>",
  "filename": "2026年7月.xlsx"
}
```

注意：base64 使体积膨胀约 33%；平台请求体上限 4.5MB，超限返回 413。原始 xlsx 建议不超过约 3.3MB。

### 响应（200）

```json
{
  "code": 0,
  "message": "ok",
  "data": {
    "months": ["2026-07"],
    "byMonth": {
      "2026-07": {
        "meta": { "year": 2026, "month": 7, "rows": 700, "hospitals": 390,
                  "totalQty": 98185, "totalConv": 9.7939, "newHospitals": 24 },
        "byHospital": [ { "name": "新疆维吾尔自治区中医医院", "qty": 2206, "conv": 0.3309, "records": 14 } ],
        "byLevel": [ { "name": "三甲", "qty": 52400, "conv": 5.24, "records": 210 } ],
        "byDistributor": [ { "name": "国药控股新疆新特药业", "qty": 30600, "conv": 3.06, "records": 102 } ],
        "byAgent": [ { "name": "张三", "qty": 21000, "conv": 2.1, "records": 88 } ],
        "byManager": [ { "name": "李四", "qty": 18000, "conv": 1.8, "records": 76 } ],
        "byProvince": [ { "name": "新疆", "qty": 52000, "conv": 5.2, "records": 201 } ],
        "newHospitals": [ { "hospital": "哈密市中心医院", "level": "二级", "province": "新疆", "qty": 320, "conv": 0.032 } ]
      }
    },
    "total": { "meta": { "year": 2026, "month": 0, "rows": 700, "hospitals": 390,
                         "totalQty": 98185, "totalConv": 9.7939, "newHospitals": 24 },
      "byHospital": [], "byLevel": [], "byDistributor": [], "byAgent": [],
      "byManager": [], "byProvince": [], "newHospitals": [] }
  }
}
```

所有分组数组按 `conv`（折算万盒）降序排列；空值分组统一显示为 `"(空)"`。

### 数据结构

- `MonthlySalesStats`
  - `months: string[]` — 月份列表，格式 `YYYY-MM`，升序
  - `byMonth: { [key: "YYYY-MM"]: SalesStats }` — 各月统计
  - `total: SalesStats` — 全部数据跨月合并统计（`meta.month` 为 0）
- `SalesStats`
  - `meta: Meta` — `{ year, month, rows, hospitals, totalQty, totalConv, newHospitals }`
    - `year/month: number` — 年 / 月（total 中 month=0）
    - `rows: number` — 有效数据行数
    - `hospitals: number` — 涉及医院数（去重）
    - `totalQty: number` — 总出库数量（盒）
    - `totalConv: number` — 总折算2贴数量（万盒）
    - `newHospitals: number` — 新增医院数（医院去重=1）
  - `byHospital / byLevel / byDistributor / byAgent / byManager / byProvince: GroupStat[]`
  - `newHospitals: NewHospital[]`
- `GroupStat` — `{ name, qty, conv, records }`
  - `name: string` — 分组名（空值显示为 `"(空)"`）
  - `qty: number` — 出库数量（盒）
  - `conv: number` — 折算2贴数量（万盒）
  - `records: number` — 记录数
- `NewHospital` — `{ hospital, level, province, qty, conv }`

### JSON Schema

完整契约见 [`/openapi.json`](https://apis.gankun.cn.lu/openapi.json)（OpenAPI 3.0 规范，含全部 components.schemas）。

## 输入文件格式（xlsx）

解析第一个工作表，**按列名取数（不依赖列位置）**，跳过「出库数量」为空的行。文件需包含以下表头列：

| 列名 | 含义 |
| --- | --- |
| `年` | 年份 |
| `月` | 月份 |
| `配送商业` | 配送商业名称 |
| `标准医院名称` | 医院名称（统计/新增医院去重依据） |
| `标准级别` | 医院级别，如 三甲 / 二级 |
| `代理商` | 代理商名称 |
| `省份` | 省份 |
| `出库数量` | 出库数量（盒） |
| `招商经理` | 招商经理名称 |
| `折算2贴数量（万盒）` | 折算2贴数量（万盒） |
| `医院去重` | =1 表示新增医院 |

## GET /api/health

无需认证，返回服务状态与已注册接口列表。

```json
{
  "code": 0,
  "message": "ok",
  "data": {
    "status": "up",
    "service": "zushima-sales-stats",
    "time": "2026-07-01T00:00:00.000Z",
    "endpoints": [
      { "method": "POST", "path": "/api/analyze", "desc": "月度销售统计（需签名认证）" },
      { "method": "GET",  "path": "/api/health",  "desc": "健康检查" }
    ]
  }
}
```

## 错误处理

所有接口统一返回 `{ code, message, data }`：成功时 `code=0`、`data` 为结果；失败时 `code` 与 HTTP 状态码一致、`data=null`。

| HTTP | code | 触发条件 |
| --- | --- | --- |
| 400 | 400 | 请求体不是合法 JSON；缺少 `file` 字段；xlsx 解析失败（错误信息在 `message`） |
| 401 | 401 | 缺少请求头 / 时间戳超出 5 分钟窗口 / 签名验证失败 / 请求体解密失败 |
| 405 | 405 | 非 POST 调用 `/api/analyze` |
| 413 | 413 | 请求体超过平台 4.5MB 上限（`FUNCTION_PAYLOAD_TOO_LARGE`） |

```json
{ "code": 401, "message": "签名验证失败", "data": null }
```

## SDK（零依赖，可直接复制）

单文件、零依赖（仅用 Web Crypto API 与 fetch），浏览器 / Node.js 通用。
将下方源码保存为 `sdk.js`（或 `sdk.mjs`）即可 `import { createClient } from './sdk.js'`——无需 npm 安装。
自动完成 HMAC-SHA256 签名认证，可选 AES-256-GCM 请求体加密。

```ts
/**
 * 祖师麻膏药销售统计 API 客户端 SDK
 *
 * 用法：
 *   import { createClient } from 'zushima-sdk';
 *
 *   const api = createClient({ baseUrl: 'https://apis.gankun.cn.lu', apiKey: 'zk_xxx', secret: 'xxx' });
 *
 *   // Node.js
 *   const res = await api.analyzeFile(readFileSync('2026年7月.xlsx'), '2026年7月.xlsx');
 *   // 浏览器
 *   const res = await api.analyzeFile(await file.arrayBuffer(), file.name);
 *
 * 零依赖，基于 Web Crypto API，浏览器 / Node.js 通用。
 * 自动完成 HMAC-SHA256 签名认证，可选 AES-256-GCM 请求体加密。
 */

/* ==================== 类型定义 ==================== */

/** 统一响应格式 */
export interface ApiResponse<T> {
  code: number;
  message: string;
  data: T;
}

/** 客户端配置 */
export interface ClientOptions {
  /** 服务地址，如 https://apis.gankun.cn.lu */
  baseUrl: string;
  /** API Key（公开标识） */
  apiKey: string;
  /** 签名密钥（保密，与服务端 API_SECRET 配合） */
  secret: string;
  /** 是否对请求体做 AES-256-GCM 加密（默认 false） */
  encrypt?: boolean;
}

/** 分组统计项 */
export interface GroupStat {
  name: string;
  qty: number;
  conv: number;
  records: number;
}

/** 新增医院明细 */
export interface NewHospital {
  hospital: string;
  level: string;
  province: string;
  qty: number;
  conv: number;
}

/** 单月统计结果 */
export interface SalesStats {
  meta: {
    year: number;
    month: number;
    rows: number;
    hospitals: number;
    totalQty: number;
    totalConv: number;
    newHospitals: number;
  };
  byHospital: GroupStat[];
  byLevel: GroupStat[];
  byDistributor: GroupStat[];
  byAgent: GroupStat[];
  byManager: GroupStat[];
  byProvince: GroupStat[];
  newHospitals: NewHospital[];
}

/** 按月份拆分的统计结果 */
export interface MonthlySalesStats {
  months: string[];
  byMonth: Record<string, SalesStats>;
  total: SalesStats;
}

/* ==================== 加密与签名 ==================== */

const enc = new TextEncoder();

function toBase64(bytes: Uint8Array): string {
  let binary = '';
  for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
  return btoa(binary);
}

function toHex(bytes: Uint8Array): string {
  return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
}

async function sha256Hex(data: string): Promise<string> {
  const digest = await crypto.subtle.digest('SHA-256', enc.encode(data));
  return toHex(new Uint8Array(digest));
}

async function hmacSha256Hex(secret: string, data: string): Promise<string> {
  const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  const sig = await crypto.subtle.sign('HMAC', key, enc.encode(data));
  return toHex(new Uint8Array(sig));
}

/** 派生 AES-256-GCM 密钥（与服务端一致：sha256(secret:apiKey)） */
async function deriveAesKey(secret: string, apiKey: string): Promise<CryptoKey> {
  const digest = await crypto.subtle.digest('SHA-256', enc.encode(`${secret}:${apiKey}`));
  return crypto.subtle.importKey('raw', digest, { name: 'AES-GCM' }, false, ['encrypt']);
}

/** AES-256-GCM 加密，输出 base64(iv.tag.ciphertext)（与服务端格式一致） */
async function aesEncrypt(secret: string, apiKey: string, plaintext: string): Promise<string> {
  const key = await deriveAesKey(secret, apiKey);
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc.encode(plaintext)));
  const tag = ct.slice(ct.length - 16);
  const data = ct.slice(0, ct.length - 16);
  return [toBase64(iv), toBase64(tag), toBase64(data)].join('.');
}

/** 构造带认证头的请求并发送 */
async function send<T>(opts: ClientOptions, path: string, body?: string): Promise<ApiResponse<T>> {
  const timestamp = Math.floor(Date.now() / 1000);
  const headers: Record<string, string> = {
    'content-type': 'application/json',
    'x-api-key': opts.apiKey,
    'x-timestamp': String(timestamp),
  };

  let payload = body ?? '';
  if (body && opts.encrypt) {
    payload = await aesEncrypt(opts.secret, opts.apiKey, body);
    headers['x-encrypted'] = '1';
  }

  // 签名覆盖实际发送的请求体
  const bodyHash = await sha256Hex(payload);
  headers['x-signature'] = await hmacSha256Hex(opts.secret, `${timestamp}:${bodyHash}`);

  const res = await fetch(`${opts.baseUrl}${path}`, {
    method: body ? 'POST' : 'GET',
    headers,
    body: body ? payload : undefined,
  });
  return res.json() as Promise<ApiResponse<T>>;
}

/* ==================== 客户端 ==================== */

/** 创建 API 客户端 */
export function createClient(opts: ClientOptions) {
  return {
    /** 上传 xlsx 并返回月度销售统计 */
    analyzeFile(data: Uint8Array | ArrayBuffer, filename?: string): Promise<ApiResponse<MonthlySalesStats>> {
      const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
      const body = JSON.stringify({ file: toBase64(bytes), filename });
      return send<MonthlySalesStats>(opts, '/api/analyze', body);
    },
    /** 健康检查 */
    health(): Promise<ApiResponse<{ status: string; endpoints: unknown[] }>> {
      return send(opts, '/api/health');
    },
  };
}
```

### SDK 用法

```ts
import { createClient } from './sdk.js';

const api = createClient({
  baseUrl: 'https://apis.gankun.cn.lu',
  apiKey: 'zk_xxx',      // API Key（公开标识）
  secret: 'xxx',         // 签名密钥（保密）
  encrypt: false,        // 可选：开启请求体 AES-256-GCM 加密
});

// Node.js
import { readFileSync } from 'node:fs';
const res = await api.analyzeFile(readFileSync('2026年7月.xlsx'), '2026年7月.xlsx');
console.log(res.data.total.meta.totalQty);      // 总出库盒数

// 浏览器（File 对象）
const file = document.getElementById('file').files[0];
const res2 = await api.analyzeFile(await file.arrayBuffer(), file.name);

// 健康检查
const h = await api.health();
console.log(h.data.status);
```

## 调用示例

### curl（shell 一行式计算签名，依赖 node 与 openssl）

```bash
API_KEY='zk_xxx'; SECRET='xxx'; FILE='2026年7月.xlsx'

# 组装请求体：{ "filename": ..., "file": "<base64>" }
BODY=$(node -e 'const fs=require("fs");process.stdout.write(JSON.stringify({filename:process.argv[1],file:fs.readFileSync(process.argv[1]).toString("base64")}))' "$FILE")

TS=$(date +%s)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 | awk '{print $2}')
SIGNATURE=$(printf '%s:%s' "$TS" "$BODY_HASH" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

curl -sS -X POST https://apis.gankun.cn.lu/api/analyze \
  -H 'content-type: application/json' \
  -H "x-api-key: $API_KEY" \
  -H "x-timestamp: $TS" \
  -H "x-signature: $SIGNATURE" \
  -d "$BODY"
```

若开启请求体加密（`x-encrypted: 1`），请直接使用上方 SDK 的 `encrypt: true`。

## 相关资源

- [`/openapi.json`](https://apis.gankun.cn.lu/openapi.json) — OpenAPI 3.0 规范
- [`/llms.txt`](https://apis.gankun.cn.lu/llms.txt) — 站点导航
- [`/`](https://apis.gankun.cn.lu/index.html) — HTML 版本文档
