服务概览

上传月度销售 Excel(xlsx),服务端自动统计总销量 / 各医院 / 各级别 / 配送商 / 代理商 / 招商经理 / 省份 / 新增医院,返回 JSON。

接口为无状态 HTTP 服务,全程 HTTPS,可自动扩缩容。除 /api/health 外均需签名认证。

Base URL:https://apis.gankun.cn.lu(请替换为实际部署域名,下同)。

接口列表

POST/api/analyze 需认证
上传 xlsx(base64),返回按月份拆分的销售统计(MonthlySalesStats)
GET/api/health 公开
健康检查,返回服务状态与接口列表

认证与签名

签名认证(HMAC-SHA256)

每个受保护请求需携带三个请求头:

请求头说明
x-api-keyAPI Key,客户端标识(可公开;凭据由服务提供方签发,本仓库附带的 scripts/gen-key.mjs 可自助生成)
x-timestamp请求时间戳(秒),服务端校验 5 分钟窗口防重放
x-signatureHMAC-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

上传 xlsx 文件(base64),返回 MonthlySalesStats JSON。

请求体(JSON)

字段类型必填说明
filestringxlsx 文件的 base64 内容(原始二进制,非 Data URL)
filenamestring原始文件名,仅用于日志/溯源
{
  "file": "<xlsx 文件的 base64 内容>",
  "filename": "2026年7月.xlsx"
}
base64 使体积膨胀约 33%;平台请求体上限 4.5MB,超限返回 413(见错误处理)。故原始 xlsx 建议不超过约 3.3MB。

响应(200)

{
  "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)降序 排列;空值分组统一显示为 "(空)"

数据结构

字段类型说明
monthsstring[]月份列表,格式 YYYY-MM,升序
byMonthobject各月统计,key 为 YYYY-MM,value 为 SalesStats
totalSalesStats全部数据跨月合并统计(meta.month 为 0)
SalesStats.meta类型说明
year / monthnumber年 / 月(total 中 month=0)
rowsnumber有效数据行数
hospitalsnumber涉及医院数(去重)
totalQtynumber总出库数量(盒)
totalConvnumber总折算2贴数量(万盒)
newHospitalsnumber新增医院数(医院去重=1)
SalesStats 数组项类型说明
byHospital / byLevel / byDistributor / byAgent / byManager / byProvinceGroupStat[]各维度分组统计,元素:name(分组名,空值显示 "(空)")/ qty(盒)/ conv(万盒)/ records(记录数)
newHospitalsNewHospital[]新增医院明细,元素:hospital / level / province / qty / conv

JSON Schema(draft 2020-12,完整版见 /openapi.json

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "MonthlySalesStats",
  "type": "object",
  "required": ["months", "byMonth", "total"],
  "properties": {
    "months": { "type": "array", "items": { "type": "string", "pattern": "^\\d{4}-\\d{2}$" },
                "description": "月份列表,如 [\"2026-07\"]" },
    "byMonth": { "type": "object", "additionalProperties": { "$ref": "#/$defs/SalesStats" },
                 "description": "各月统计,key 为 YYYY-MM" },
    "total": { "$ref": "#/$defs/SalesStats", "description": "全部数据合并统计(跨月汇总)" }
  },
  "$defs": {
    "SalesStats": {
      "type": "object",
      "required": ["meta", "byHospital", "byLevel", "byDistributor", "byAgent", "byManager", "byProvince", "newHospitals"],
      "properties": {
        "meta": { "$ref": "#/$defs/Meta" },
        "byHospital":  { "type": "array", "items": { "$ref": "#/$defs/GroupStat" } },
        "byLevel":     { "type": "array", "items": { "$ref": "#/$defs/GroupStat" } },
        "byDistributor": { "type": "array", "items": { "$ref": "#/$defs/GroupStat" } },
        "byAgent":     { "type": "array", "items": { "$ref": "#/$defs/GroupStat" } },
        "byManager":   { "type": "array", "items": { "$ref": "#/$defs/GroupStat" } },
        "byProvince":  { "type": "array", "items": { "$ref": "#/$defs/GroupStat" } },
        "newHospitals": { "type": "array", "items": { "$ref": "#/$defs/NewHospital" } }
      }
    },
    "Meta": {
      "type": "object",
      "required": ["year", "month", "rows", "hospitals", "totalQty", "totalConv", "newHospitals"],
      "properties": {
        "year":  { "type": "integer", "description": "年" },
        "month": { "type": "integer", "description": "月(跨月汇总时为 0)" },
        "rows":  { "type": "integer", "description": "数据行数" },
        "hospitals": { "type": "integer", "description": "涉及医院数(去重)" },
        "totalQty": { "type": "number", "description": "总出库数量(盒)" },
        "totalConv": { "type": "number", "description": "总折算2贴数量(万盒)" },
        "newHospitals": { "type": "integer", "description": "新增医院数(医院去重=1)" }
      }
    },
    "GroupStat": {
      "type": "object",
      "required": ["name", "qty", "conv", "records"],
      "properties": {
        "name": { "type": "string", "description": "分组名称(空值显示为 \"(空)\")" },
        "qty": { "type": "number", "description": "出库数量(盒)" },
        "conv": { "type": "number", "description": "折算2贴数量(万盒)" },
        "records": { "type": "integer", "description": "记录数" }
      }
    },
    "NewHospital": {
      "type": "object",
      "required": ["hospital", "level", "province", "qty", "conv"],
      "properties": {
        "hospital": { "type": "string", "description": "标准医院名称" },
        "level": { "type": "string", "description": "标准级别" },
        "province": { "type": "string", "description": "省份" },
        "qty": { "type": "number", "description": "出库数量(盒)" },
        "conv": { "type": "number", "description": "折算2贴数量(万盒)" }
      }
    }
  }
}

输入文件格式(xlsx)

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

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

GET /api/health

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

{
  "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=0data 为结果;失败时 code 与 HTTP 状态码一致、data=null

HTTPcode触发条件
400400请求体不是合法 JSON;缺少 file 字段;xlsx 解析失败(错误信息在 message
401401缺少请求头 / 时间戳超出 5 分钟窗口 / 签名验证失败 / 请求体解密失败
405405非 POST 调用 /api/analyze
413413请求体超过平台 4.5MB 上限(FUNCTION_PAYLOAD_TOO_LARGE
{ "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 请求体加密。

/**
 * 祖师麻膏药销售统计 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 用法

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)

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

# 组装请求体:{ "filename": ..., "file": "" }
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 · OpenAPI 3.0 规范 /llms.txt · 站点导航(llmstxt.org) /llms.md · 纯 Markdown 完整文档(LLM 友好)

供 API 工具链与 AI 代理直接读取的规范与文档,内容与本页一致。