@zhumi/ehome-maycur-openapi-sdk
v1.1.3
Published
每刻(Maycur)开放平台 OpenAPI SDK — Node.js / TypeScript 版本,参照 Java 版 ehome-maycur-openapi-sdk 实现
Maintainers
Readme
@zhumi/ehome-maycur-openapi-sdk
每刻(Maycur)开放平台 OpenAPI SDK 的 Node.js / TypeScript 版本,参照 Java 版
ehome-maycur-openapi-sdk 实现,接口、模型与枚举一一对应。
- 零运行时依赖(使用 Node 18+ 内置
fetch) - 完整类型声明,全部接口带中文 JSDoc
- 同时提供 ESM 与 CommonJS 产物
- 支持多应用(多
appId)并存、动态注册 - 内置认证与 20 分钟认证缓存,首次请求自动登录并注入 token
- 支持每刻的请求体 AES 加密模式
安装
npm install @zhumi/ehome-maycur-openapi-sdk要求 Node.js >= 18.17。
快速开始
import { MaycurSdk, isMaycurSuccess } from '@zhumi/ehome-maycur-openapi-sdk';
const sdk = new MaycurSdk({
apps: {
AP59TWMZD0CMEI: {
url: 'https://ng-uat.maycur.com',
appSecret: process.env.MAYCUR_APP_SECRET!,
},
},
});
// 首次调用会自动完成「登录 + 注入 token」
const res = await sdk.department.getDepartmentRoot('AP59TWMZD0CMEI');
if (isMaycurSuccess(res)) {
console.log(res.data);
} else {
console.error(`调用失败 code=${res.code} message=${res.message}`);
}CommonJS 同样可用:
const { MaycurSdk, isMaycurSuccess } = require('@zhumi/ehome-maycur-openapi-sdk');绑定单个应用
不想每次都传 appId 时,用 forApp() 拿到绑定后的门面(类型会去掉第一个 appId 参数):
const app = sdk.forApp('AP59TWMZD0CMEI');
await app.department.getDepartmentRoot();
await app.employee.employeeDetails({ employeeIds: ['E001'], detailedAccountInfo: true });在浏览器 / Chrome 扩展中使用
SDK 提供双入口,同一套 API:
| 场景 | 导入方式 | 加密实现 | 认证缓存 |
| --- | --- | --- | --- |
| Node.js | @zhumi/ehome-maycur-openapi-sdk | node:crypto | 文件缓存(默认,兼容 Java) |
| 浏览器 / Chrome 扩展 | @zhumi/ehome-maycur-openapi-sdk/browser | WebCrypto + 纯 TS SHA-256/MD5 | 内存缓存 |
用打包器(Vite / webpack / esbuild)构建面向浏览器的产物时,也会通过
exports 的 browser 条件自动选中浏览器入口,因此直接写包名即可:
// Chrome 扩展(MV3)中
import { MaycurSdk, isMaycurSuccess } from '@zhumi/ehome-maycur-openapi-sdk';
const sdk = new MaycurSdk({
apps: {
AP59TWMZD0CMEI: {
url: 'https://ng-uat.maycur.com',
appSecret: '...', // 浏览器里没有 process.env,建议由配置面板写入 chrome.storage
},
},
authCache: 'memory',
});
// 发票导入(含 OCR 识别)
const res = await sdk.invoice.invoicePicImport('AP59TWMZD0CMEI', {
fileBase64: base64OfPdf,
fileType: 'PDF',
employeeId: 'E001',
});浏览器入口的差异
- 不导出依赖 Node 的能力:
createFileAuthCache、loadAppsFromFile、maycurFileFromPath、encryptCbcMode/decryptCbcMode/deriveAesKey(返回 Buffer 的版本)。 等价能力请使用MaycurFile.fromBlob/MaycurFile.fromBuffer、deriveAesKeyBytes,认证缓存用内存实现。 - Chrome 扩展需要声明目标域名的
host_permissions(例如https://*.maycur.com/*)。 - MV3 的
extension_pages无需为 WASM 调整 CSP —— 浏览器入口不使用 WASM。
为什么能跨运行时
| 关注点 | 处理方式 |
| --- | --- |
| SHA-256 / MD5 | 纯 TypeScript 实现(同步、零依赖),与 node:crypto 逐字节一致 |
| AES-CBC | Node 用 node:crypto;浏览器用 WebCrypto,并运行时探测该实现是否自动做 PKCS#7 填充,两种行为都能与 Java 服务端互通 |
| Base64 / UTF-8 | 自带实现,不依赖 Buffer / btoa |
| 认证缓存 | AuthCache 接口 + 入口注入的工厂(文件 / 内存) |
| 文件与流 | MaycurFile 支持 Uint8Array / ArrayBuffer / Blob;流支持 Web ReadableStream 与 Node 可读流 |
npm run build 后可用 npm run test:dist 校验浏览器产物不含任何 node: 内置模块引用:
构建产物检查:
dist/browser.js 无 Node 内置模块依赖 ✓
dist/index.js 使用 node:crypto ✓
构建产物检查通过:browser 入口可在 Chrome 扩展中直接打包使用。配置
构造选项
new MaycurSdk({
apps: {
[appId]: {
url: 'https://ng-uat.maycur.com', // 必填,网关地址
appSecret: 'xxx', // 必填,应用密钥
needAes: false, // 可选,是否启用请求体 AES 加密
dataSecret: 'xxx', // 可选,needAes 为 true 时必填
authCacheFile: './cache-#appCode.json', // 可选,支持 #appCode 占位符
describe: '测试环境', // 可选,备注
},
},
autoAuth: true, // 默认 true:首次请求自动登录
authCache: 'file', // 'file'(默认,兼容 Java)| 'memory'
authCacheDir: './.ehome-maycur-cache',// 可选,覆盖缓存文件目录
authCacheFile: './ehome.maycur.auth_cache-#appCode.json', // 可选,全局模板
timeoutMs: 30_000, // 默认 30s
uploadFieldName: 'file', // 默认 file
logLevel: 'silent', // silent(默认)| error | warn | info | debug
logger: myLogger, // 可选,自定义 logger(优先于 logLevel)
fetch: myFetch, // 可选,注入自定义 fetch(测试用)
});从 JSON 文件加载应用配置
兼容 application.yml 的结构,同时也接受 { apps: ... } 或直接给出 apps 映射:
{
"ehome": {
"maycur": {
"apps": {
"AP59TWMZD0CMEI": {
"url": "https://ng-uat.maycur.com",
"appSecret": "xxx",
"needAes": false,
"authCacheFile": "./ehome.maycur.auth_cache-#appCode.json"
}
}
}
}
}import { MaycurSdk, loadAppsFromFile } from '@zhumi/ehome-maycur-openapi-sdk';
const sdk = new MaycurSdk({ apps: loadAppsFromFile('./ehome.maycur.json') });运行时增删应用
sdk.addApp('OTHER_APP_ID', { url: 'https://ng.maycur.com', appSecret: 'xxx' });
sdk.listAppIds(); // ['AP59TWMZD0CMEI', 'OTHER_APP_ID']
sdk.hasClient('OTHER_APP_ID'); // true
sdk.removeApp('OTHER_APP_ID');自定义日志
const sdk = new MaycurSdk({
apps: { /* ... */ },
logger: {
error: (...a) => myLogger.error(...a),
warn: (...a) => myLogger.warn(...a),
info: (...a) => myLogger.info(...a),
debug: (...a) => myLogger.debug(...a),
},
});认证
自动认证(默认)
autoAuth 默认为 true:客户端在每次请求前检查是否持有 token,没有则调用
/api/openapi/auth/login 登录并注入 tokenId / entCode。并发请求会合并为一次登录。
Java 版需要业务侧显式调用
authService.login()再maycurClientManager.getClient(appid).refreshToken(...);Node 版默认免去这两步。
手工认证
const login = await sdk.auth.login('AP59TWMZD0CMEI');
sdk.getClient('AP59TWMZD0CMEI').refreshToken(login.data!.tokenId!, login.data!.entCode!);
// 关闭自动认证,完全自行控制
const manual = new MaycurSdk({ apps: { /* ... */ }, autoAuth: false });
// 退出 / 清理缓存
await sdk.auth.logout('AP59TWMZD0CMEI');签名算法与 Java 版完全一致:sha256(appSecret + ":" + appId + ":" + 毫秒时间戳),小写十六进制。
认证结果按 20 分钟 有效期缓存,默认写入文件(以文件 mtime 判断有效期,与 Java 版相同)。
无文件系统的运行环境(Serverless、只读容器)请使用 authCache: 'memory'。
文件上传
上传使用 multipart/form-data,中文文件名以原始 UTF-8 字节写入
Content-Disposition,与 Java 版 HttpMultipartMode.BROWSER_COMPATIBLE + UTF-8 行为一致,
因此 银行回单.pdf 不会乱码。
import { MaycurFile } from '@zhumi/ehome-maycur-openapi-sdk';
// 从磁盘读取
const file = await MaycurFile.fromPath('./银行回单.pdf');
// 或从内存 / Blob 构造
const fromMemory = MaycurFile.fromBuffer(Buffer.from('%PDF-1.4 ...'), '回单.pdf');
const fromBlob = MaycurFile.fromBlob(new Blob([bytes]), '回单.pdf');
// 上传 -> 拿到 fileKey
const uploaded = await sdk.thirdPaymentWithPaybillMode.uploadReceipt('AP59...', file);
// 用 fileKey 绑定支付流水
await sdk.thirdPaymentWithPaybillMode.associateReceiptToPaymentOrder(
'AP59...',
paymentNo,
uploaded.data!.fileKey!,
file.fileName,
);也支持流式上传(必须显式给出文件名):
import { createReadStream } from 'node:fs';
await sdk.thirdPaymentWithPaybillMode.uploadReceipt(
'AP59...',
createReadStream('./回单.pdf'),
'回单.pdf',
);AES 请求体加密
当应用配置 needAes: true 且提供 dataSecret 时:
- 请求体加密为
{"encryptedBody":"<Base64>"},并带上securityType: AES头 - 响应若为
{"encryptedBody":"..."}信封或整体密文,会自动解密 - 若服务端对某接口返回的是明文 JSON,会按明文处理而不会强行解密
密钥派生与加解密完全对齐 Java 版:
- 密钥 =
Base64(dataSecret)的前 16 个 ASCII 字符,不足 16 字节补0(故实际为 AES-128) - IV = 密钥本身(每刻平台的既有约定,为保持互通未做改动)
- 明文 UTF-8 编码,密文标准 Base64,分组填充 PKCS7(等价于 Java 的 PKCS5Padding)
仓库内的 test/aes.test.ts 使用 真实 JVM 运行 Java 算法产出的向量
(tools/codegen/AesVector.java → tools/codegen/aes-vectors.json)做逐字节校验。
安全性提示:IV 固定且等于密钥是每刻开放平台的接口约定,SDK 为保证与 Java 版、 与服务端互通而必须沿用,请勿据此评估其密码学强度。
错误处理
SDK 区分「传输层失败」与「业务失败」:
| 情况 | 行为 |
| --- | --- |
| HTTP 请求失败 / 响应非 JSON / 响应为空 | 抛 MaycurHttpError(含 status、apiPath、responseText) |
| 请求超时 | 抛 MaycurTimeoutError(继承自 MaycurHttpError) |
| 登录失败或未拿到 token | 抛 MaycurAuthError |
| 配置缺失或非法 | 抛 MaycurConfigError |
| 接口返回 success: false | 不抛异常,原样返回响应(与 Java 版只打 warn 的行为一致) |
import { isMaycurSuccess, MaycurHttpError } from '@zhumi/ehome-maycur-openapi-sdk';
try {
const res = await sdk.department.getDepartmentRoot('AP59TWMZD0CMEI');
if (!isMaycurSuccess(res)) {
// 业务失败:res.code / res.message / res.requestId
}
} catch (error) {
if (error instanceof MaycurHttpError) {
console.error(error.status, error.apiPath, error.responseText);
}
}
isMaycurSuccess()与 Java 的MaycurResponse.isSuccess()语义一致:仅当success === true为真。注意部分接口的文档描述code === '0'表示成功, 但 Java 版判定的是success字段,本 SDK 保持一致。
接口一览
所有服务挂在 sdk 上,方法签名与 Java 版一致,第一个参数为 appId,返回
Promise<MaycurResponse<T>>。共 23 个服务、219 个方法。
| SDK 属性 | Java 服务类 | 方法数 |
| --- | --- | --- |
| sdk.auth | MaycurAuthService | 认证登录 / 缓存管理 |
| sdk.billApproveWebhook | BillApproveWebhookService | 9 |
| sdk.cascade | CascadeService | 3 |
| sdk.department | DepartmentService | 7 |
| sdk.employee | EmployeeService | 10 |
| sdk.expense | ExpenseService | 11 |
| sdk.formContract | FormContractService | 13 |
| sdk.formLoan | FormLoanService | 14 |
| sdk.formPayment | FormPaymentService | 6 |
| sdk.formPreConsume | FormPreConsumeService | 16 |
| sdk.formReimburse | FormReimburseService | 14 |
| sdk.formRepayment | FormRepaymentService | 8 |
| sdk.formSubType | FormSubTypeService | 1 |
| sdk.installment | InstallmentService | 6 |
| sdk.invoice | InvoiceService | 7 |
| sdk.legalEntity | LegalEntityService | 9 |
| sdk.location | LocationService | 1 |
| sdk.referenceDataDetail | ReferenceDataDetailService | 7 |
| sdk.role | RoleService | 3 |
| sdk.thirdPaymentWithoutPaybillMode | ThirdPaymentWithoutPaybillModeService | 9 |
| sdk.thirdPaymentWithPaybillMode | ThirdPaymentWithPaybillModeService | 16 |
| sdk.tradingPartner | TradingPartnerService | 8 |
| sdk.userGroup | UserGroupService | 15 |
| sdk.voucher | VoucherService | 25 |
请求/响应模型位于 src/model/**,与 Java 的 model 包逐文件对应,
共 336 个类型(含 33 个枚举),全部带中文 TSDoc,IDE 里可直接看到字段说明。
import type {
BatchSaveDepartmentRequest,
DepartmentListSearchRequest,
} from '@zhumi/ehome-maycur-openapi-sdk';
const departments: BatchSaveDepartmentRequest[] = [
{ businessCode: 'D001', name: '研发部', parentBizCode: 'ROOT', enabled: true },
];
const search: DepartmentListSearchRequest = { pageNo: 1, pageSize: 20, keyword: '' };枚举
Java 枚举在 TypeScript 中按形态映射,取值与 Java 序列化到 JSON 的值一致:
import {
FormType, FormTypeDescriptions, // 单参数:取值 = 常量名,额外提供说明文字映射
DepartmentErrorCode, // 双参数(code, description):取值 = code
VoucherFinanceCodeEnum, // 多字段:常量对象注册表
voucherFinanceCodeEnumFromFinanceCode, // Java 静态工厂方法对应的查找助手
} from '@zhumi/ehome-maycur-openapi-sdk';
FormType.REIMBURSE; // 'REIMBURSE'
FormTypeDescriptions[FormType.REIMBURSE]; // '报销单'
DepartmentErrorCode.SUCCESS; // 'ACK'
VoucherFinanceCodeEnum.SAP_SAP76; // { name: 'SAP', financeVersion: 'SAP7.6', ... }
voucherFinanceCodeEnumFromFinanceCode('F2501')?.name; // 'SAP'Java 的嵌套类型(如 BatchSaveDepartmentRequest.SpecifyDeptAndUser)通过
declare namespace 合并保留同名引用方式:
import type { BatchSaveDepartmentRequest } from '@zhumi/ehome-maycur-openapi-sdk';
const item: BatchSaveDepartmentRequest.SpecifyDeptAndUser = {
type: 'department',
bizCode: 'D002',
};与 Java 版的差异
除语言差异(异步 Promise、构造函数替代 Spring 注入)外,有以下有意的行为差异:
| 项 | Java 版 | 本 SDK | 原因 |
| --- | --- | --- | --- |
| 认证 | 需业务侧显式 login() + refreshToken() | autoAuth 默认自动完成(可关闭) | 免去样板代码,等价于 Java 版声明但未实现的 AOP 切面意图 |
| GET 查询参数 | 循环中误用 fieldNames().next(),只有第一个字段生效 | 拼接全部字段 | 修复 Java 版的缺陷 |
| 空请求体 | POST 时发送字面量 null | 不发送请求体 | 更符合 HTTP 语义,服务端行为一致 |
| 上传来源 | java.io.File / InputStream | MaycurFile / 可读流 | Node 无对应类型 || 响应解密字符集 | new String(bytes) 使用平台默认字符集(Windows 上为 GBK,中文会乱码) | 固定 UTF-8 | 修复 Java 版在非 UTF-8 平台的缺陷 |
| 认证缓存 | 仅文件缓存 | 文件缓存(默认)+ 内存缓存 | 适配 Serverless / 只读文件系统 |
| 服务层日志 | 每个方法 log.info(...) | 统一在客户端按 logLevel 输出请求/响应 | 避免生成代码里堆积 Java 风格字符串拼接 |
已知上游问题(本 SDK 保持与 Java 版一致,未擅自修改)
ExpenseService.expenseThirdPartyQuery 的接口常量在 Java 版中是
/openapi/expense/thirdParty/%s,缺少 /api 前缀(同文件其它常量均为 /api/openapi/...)。
本 SDK 按原样保留以避免与 Java 版行为分叉。若确认是笔误,请同时修正 Java 版与本 SDK
的生成源(tools/codegen)。
代码生成与维护
src/model/** 与 src/service/** 由 Java 源码自动生成,请勿手工修改。
生成器位于 tools/codegen,从 Java 版 SDK 的源码解析出中间表示后产出 TypeScript。
# 重新生成(默认读取 ../ehome-maycur-openapi-sdk 的源码)
npm run gen
# 校验生成结果与 Java 源码一致(动词 / 路径 / 参数个数)
npm run verify
# 类型检查 / 测试 / 构建
npm run typecheck
npm test
npm run buildtools/codegen 各文件职责:
| 文件 | 作用 |
| --- | --- |
| cst.mjs | java-parser CST 遍历工具、顶层逗号切分 |
| java-ir.mjs | Java 源码 → 中间表示(类/枚举/字段/方法/嵌套类型) |
| types.mjs | Java 类型 → TypeScript 类型映射 |
| tsdoc.mjs | Javadoc → TSDoc |
| emit-model.mjs | 中间表示 → interface / enum 模块 |
| emit-service.mjs | 中间表示 → 服务类模块 |
| overrides.mjs | 少量无法自动翻译的方法(Java 重载、便捷方法、上传)的手工实现 |
| index.mjs | 生成入口 |
| verify.mjs | 独立校验脚本 |
| AesVector.java | 在真实 JVM 上产出 AES 互通测试向量 |
生成结果覆盖情况(npm run gen 会输出):
model 文件 : 311
service 文件: 23
顶层类型 : 336(其中 enum 33)
嵌套类型 : 331
字段 : 3819
服务方法 : 219开发与测试
npm install
npm run gen # 生成代码
npm run verify # 与 Java 源码交叉校验
npm run typecheck # tsc --noEmit(覆盖 src / test / examples)
npm test # vitest:单元测试 + mock 服务器端到端测试
npm run build # 产出 ESM + CJS + .d.ts测试分层:
test/aes.test.ts— AES 与 真实 JVM 产出的向量 逐字节互通校验test/config.test.ts— 配置解析、#appCode占位符、AES 配置校验、JSON 加载test/auth-cache.test.ts— 文件缓存 TTL(mtime 语义)与内存缓存test/client.test.ts— 请求头、GET 查询串、AES 请求/响应、超时、错误映射、multipart 中文文件名test/models.test.ts— 枚举各形态、嵌套类型命名空间、泛型模型、字段类型映射test/e2e.test.ts— 本地 mock HTTP 服务器跑通签名、自动登录、token 注入、AES 全链路、上传与错误传播
示例位于仓库的 examples/ 目录,与 Java 版 Demo 一一对应(该目录不随 npm 包发布):
| 示例 | Java Demo |
| --- | --- |
| examples/01-auth.ts | BaseDemo |
| examples/02-department.ts | DepartmentDemo |
| examples/03-employee-account.ts | EmployeeAccountDemo |
| examples/04-employer.ts | EmployerDemo |
| examples/05-legal-entity.ts | LegalEngityDemo |
| examples/06-preconsume-import.ts | PreConsumeImportDemo |
| examples/07-receipt-bind.ts | ReceiptBindDemo |
| examples/08-reference-data-detail.ts | ReferenceDataDetailDemo |
| examples/09-reimburse-invoice.ts | ReimburseInvoiceDemo |
| examples/10-third-payment.ts | ThirdPaymentDemo |
| examples/11-trading-partner.ts | TradingPartnerDemo |
| examples/12-voucher.ts | VoucherDemo |
运行方式(需要先设置 MAYCUR_BASE_URL / MAYCUR_APP_SECRET 等环境变量,详见 examples/README.md):
npm run example examples/02-department.ts发布
发布到 npm 的只有编译产物 dist/(Node 与浏览器两个入口,均为 ESM + CJS + 类型声明),
源码、示例与代码生成器保留在仓库中,不随包发布。
npm run clean && npm run gen && npm run verify && npm run typecheck && npm test && npm run build && npm run test:dist
npm publishprepublishOnly 已把上述步骤串起来。首次发布到 npmjs 需要指定 scope 的访问权限:
npm publish --access public发布后建议用 npm pack 安装到临时工程验证三种入口都能工作:
npm pack
# 在临时工程中
npm install ./zhumi-ehome-maycur-openapi-sdk-1.0.0.tgz
node -e "console.log(Object.keys(require('@zhumi/ehome-maycur-openapi-sdk')).length)" # CJS
node --input-type=module -e "import('@zhumi/ehome-maycur-openapi-sdk').then(m=>console.log(typeof m.MaycurSdk))" # ESM