Skip to content

Commit

Permalink
feat: AlipayFormData 支持文件流和文件内容上传文件 (#134)
Browse files Browse the repository at this point in the history
fix: 设置文件上传分片 chunk 最小为 2MB, 避开 OpenAPI 接入层的 chunk 数量限制
基于 node-modules/formstream#26 实现

closes #130
closes #135
  • Loading branch information
fengmk2 authored Jun 6, 2024
1 parent 53f523b commit 046f4e7
Show file tree
Hide file tree
Showing 8 changed files with 258 additions and 47 deletions.
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,58 @@ console.log(uploadResult);
// }
```

#### 上传文件流

```ts
import fs from 'node:fs';
import { AlipayFormData } from 'alipay-sdk';

const form = new AlipayFormData();
form.addFile('file_content', '图片.jpg', fs.createReadStream('/path/to/test-file'));

const uploadResult = await alipaySdk.curl<{
file_id: string;
}>('POST', '/v3/alipay/open/file/upload', {
form,
body: {
biz_code: 'openpt_appstore',
},
});

console.log(uploadResult);
// {
// data: { file_id: 'A*7Cr9T6IAAC4AAAAAAAAAAAAAATcnAA' },
// responseHttpStatus: 200,
// traceId: '06033316171731110716358764348'
// }
```

#### 上传文件内容

```ts
import fs from 'node:fs';
import { AlipayFormData } from 'alipay-sdk';

const form = new AlipayFormData();
form.addFile('file_content', '图片.jpg', fs.readFileSync('/path/to/test-file'));

const uploadResult = await alipaySdk.curl<{
file_id: string;
}>('POST', '/v3/alipay/open/file/upload', {
form,
body: {
biz_code: 'openpt_appstore',
},
});

console.log(uploadResult);
// {
// data: { file_id: 'A*7Cr9T6IAAC4AAAAAAAAAAAAAATcnAA' },
// responseHttpStatus: 200,
// traceId: '06033316171731110716358764348'
// }
```

### pageExecute 示例代码

`pageExecute` 方法主要是用于网站支付接口请求链接生成,传入前台访问输入密码完成支付,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"bignumber.js": "^9.1.2",
"camelcase-keys": "^7.0.2",
"crypto-js": "^4.2.0",
"formstream": "^1.4.0",
"formstream": "^1.5.0",
"snakecase-keys": "^8.0.0",
"sse-decoder": "^1.0.0",
"urllib": "^3.25.0",
Expand Down
19 changes: 12 additions & 7 deletions src/AlipayFormStream.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
// import { statSync } from 'node:fs';
import FormStream from 'formstream';

export interface AlipayFormStreamOptions {
/** min chunk size to emit data event */
minChunkSize?: number;
}

export class AlipayFormStream extends FormStream {
// 覆盖 file 方法,由于 OpenAPI 文件上传需要强制设置 content-length,所以需要增加一次同步文件 io 来实现此功能
// https://github.com/node-modules/formstream/blob/master/lib/formstream.js#L119
// file(name: string, filepath: string, filename: string) {
// const size = statSync(filepath).size;
// return super.file(name, filepath, filename, size);
// }
constructor(options?: AlipayFormStreamOptions) {
super({
// set default minChunkSize to 2MB
minChunkSize: 1024 * 1024 * 2,
...options,
});
}
}
45 changes: 33 additions & 12 deletions src/alipay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,13 @@ export class AlipaySdk {
form.field('data', httpRequestBody, 'application/json');
// 文件上传 https://opendocs.alipay.com/open-v3/054oog#%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0
for (const item of options.form.files) {
form.file(item.fieldName, item.path, item.name);
if (item.path) {
form.file(item.fieldName, item.path, item.name);
} else if (item.content) {
form.buffer(item.fieldName, item.content, item.name);
} else if (item.stream) {
form.stream(item.fieldName, item.stream, item.name);
}
}
} else if (options.form instanceof AlipayFormStream) {
form = options.form;
Expand Down Expand Up @@ -504,11 +510,10 @@ export class AlipaySdk {
}

// 文件上传
async #multipartExec(method: string, options: IRequestOption = {}): Promise<AlipaySdkCommonResult> {
async #multipartExec(method: string, options: IRequestOption): Promise<AlipaySdkCommonResult> {
const config = this.config;
let signParams = {} as Record<string, string>;
let formData = {} as { [key: string]: string | object };
const formFiles = {} as { [key: string]: string };
let formData = {} as Record<string, string>;
options.formData!.getFields().forEach(field => {
// formData 的字段类型应为 string。(兼容 null)
const parsedFieldValue = typeof field.value === 'object' && field.value ?
Expand All @@ -521,12 +526,34 @@ export class AlipaySdk {
// 签名方法中使用的 key 是驼峰
signParams = camelcaseKeys(signParams, { deep: true });
formData = snakeCaseKeys(formData);

const formStream = new AlipayFormStream();
for (const k in formData) {
formStream.field(k, formData[k]);
}
options.formData!.getFiles().forEach(file => {
// 文件名需要转换驼峰为下划线
const fileKey = decamelize(file.fieldName);
// 单独处理文件类型
formFiles[fileKey] = file.path;
if (file.path) {
formStream.file(fileKey, file.path, file.name);
} else if (file.stream) {
formStream.stream(fileKey, file.stream, file.name);
} else if (file.content) {
formStream.buffer(fileKey, file.content, file.name);
}
});
const requestOptions: RequestOptions = {
method: 'POST',
dataType: 'text',
timeout: config.timeout,
headers: {
'user-agent': this.version,
accept: 'application/json',
...formStream.headers(),
},
content: new Readable().wrap(formStream as any),
};
// 计算签名
const signData = sign(method, signParams, config);
// 格式化 url
Expand All @@ -536,13 +563,7 @@ export class AlipaySdk {
url, method, signParams);
let httpResponse: HttpClientResponse<string>;
try {
httpResponse = await urllib.request(url, {
timeout: config.timeout,
headers: { 'user-agent': this.version },
files: formFiles,
data: formData,
dataType: 'text',
});
httpResponse = await urllib.request(url, requestOptions);
} catch (err: any) {
debug('HttpClient Request error: %s', err);
throw new AlipayRequestError(`HttpClient Request error: ${err.message}`, {
Expand Down
63 changes: 38 additions & 25 deletions src/form.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,16 @@
// forked form https://github.com/joaquimserafim/is-json/blob/master/index.js#L6
function isJSONString(value: any) {
if (typeof value !== 'string') return false;
value = value.replace(/\s/g, '').replace(/\n|\r/, '');
if (/^\{(.*?)\}$/.test(value)) {
return /"(.*?)":(.*?)/g.test(value);
}

if (/^\[(.*?)\]$/.test(value)) {
return value.replace(/^\[/, '')
.replace(/\]$/, '')
.replace(/},{/g, '}\n{')
.split(/\n/)
.map((s: string) => { return isJSONString(s); })
.reduce(function(_prev: string, curr: string) { return !!curr; });
}
return false;
}
import { Readable } from 'node:stream';

export interface IFile {
/** 文件名 */
name: string;
/** 文件路径 */
path: string;
/** 表单字段名 */
fieldName: string;
/** 文件路径 */
path?: string;
/** 文件流 */
stream?: Readable;
/** 文件内容 */
content?: Buffer;
}

export interface IField {
Expand Down Expand Up @@ -72,13 +59,39 @@ export class AlipayFormData {
* 增加文件
* @param fieldName 文件字段名
* @param fileName 文件名
* @param filePath 文件绝对路径
* @param filePath 文件绝对路径,或者文件流,又或者文件内容 Buffer
*/
addFile(fieldName: string, fileName: string, filePath: string) {
this.files.push({
addFile(fieldName: string, fileName: string, filePath: string | Readable | Buffer) {
const file: IFile = {
fieldName,
name: fileName,
path: filePath,
});
};
if (typeof filePath === 'string') {
file.path = filePath;
} else if (Buffer.isBuffer(filePath)) {
file.content = filePath;
} else {
file.stream = filePath;
}
this.files.push(file);
}
}

// forked form https://github.com/joaquimserafim/is-json/blob/master/index.js#L6
function isJSONString(value: any) {
if (typeof value !== 'string') return false;
value = value.replace(/\s/g, '').replace(/\n|\r/, '');
if (/^\{(.*?)\}$/.test(value)) {
return /"(.*?)":(.*?)/g.test(value);
}

if (/^\[(.*?)\]$/.test(value)) {
return value.replace(/^\[/, '')
.replace(/\]$/, '')
.replace(/},{/g, '}\n{')
.split(/\n/)
.map((s: string) => { return isJSONString(s); })
.reduce(function(_prev: string, curr: string) { return !!curr; });
}
return false;
}
2 changes: 1 addition & 1 deletion src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export function sign(method: string, params: Record<string, any>, config: Requir
const algorithm = ALIPAY_ALGORITHM_MAPPING[config.signType];
decamelizeParams.sign = createSign(algorithm)
.update(signString, 'utf8').sign(config.privateKey, 'base64');
debug('algorithm: %s, signString: %o, sign: %o', algorithm, signString, sign);
debug('algorithm: %s, signString: %o, sign: %o', algorithm, signString, decamelizeParams.sign);
return decamelizeParams;
}

Expand Down
35 changes: 35 additions & 0 deletions test/alipay.exec.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Verify } from 'node:crypto';
import { strict as assert } from 'node:assert';
import fs from 'node:fs';
import urllib, { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'urllib';
import mm from 'mm';
import {
Expand Down Expand Up @@ -525,6 +526,40 @@ describe('test/alipay.exec.test.ts', () => {
// console.log(result);
});

it('支持流式上传文件', async () => {
const filePath = getFixturesFile('demo.jpg');
const form = new AlipayFormData();
form.addField('biz_code', 'openpt_appstore');
form.addFile('file_content', '图片.jpg', fs.createReadStream(filePath));

const result = await sdk.exec('alipay.open.file.upload', {}, {
formData: form,
validateSign: true,
});
assert.equal(result.code, '10000');
assert.equal(result.msg, 'Success');
assert(result.traceId!.length >= 29);
assert(result.fileId);
console.log(result);
});

it('支持 buffer 上传文件', async () => {
const filePath = getFixturesFile('demo.jpg');
const form = new AlipayFormData();
form.addField('biz_code', 'openpt_appstore');
form.addFile('file_content', '图片.jpg', fs.readFileSync(filePath));

const result = await sdk.exec('alipay.open.file.upload', {}, {
formData: form,
validateSign: true,
});
assert.equal(result.code, '10000');
assert.equal(result.msg, 'Success');
assert(result.traceId!.length >= 29);
assert(result.fileId);
console.log(result);
});

it('should handle urllib request error', async () => {
const filePath = getFixturesFile('demo.jpg');
const form = new AlipayFormData();
Expand Down
Loading

0 comments on commit 046f4e7

Please sign in to comment.