@xiashe/sdk 0.1.29 → 0.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/alipayWallet.mjs +136 -40
- package/index.mjs +1 -1
- package/package.json +1 -1
package/alipayWallet.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { chmod, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import os from 'node:os';
|
|
@@ -8,10 +8,18 @@ const DEFAULT_TIMEOUT_MS = 8 * 60 * 1000;
|
|
|
8
8
|
const RESOURCE_FETCH_TIMEOUT_MS = 20 * 1000;
|
|
9
9
|
const MAX_OUTPUT_BYTES = 32 * 1024;
|
|
10
10
|
const SENSITIVE_OUTPUT = /payment[-_ ]?proof|payment[-_ ]?needed|seller_signature|runtimeToken|authorization\s*:/i;
|
|
11
|
-
const SENSITIVE_USER_OUTPUT = /payment[-_ ]?proof|payment[-_ ]?needed|seller_signature|runtimeToken|authorization\s*:|trade[_ -]?no|交易(?:流水)
|
|
11
|
+
const SENSITIVE_USER_OUTPUT = /payment[-_ ]?proof|payment[-_ ]?needed|seller_signature|runtimeToken|authorization\s*:|trade[_ -]?no|交易(?:流水)?号|订单号|查询单号|out[_ -]?shake|client[_ -]?session|payguard|validateclientparent|agentfamily/i;
|
|
12
12
|
const DEFAULT_STATE_DIR = path.join(os.homedir(), '.xiashe', 'alipay-402');
|
|
13
13
|
const OFFICIAL_MEDIA_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
|
14
14
|
const MAX_OFFICIAL_MEDIA_BYTES = 8 * 1024 * 1024;
|
|
15
|
+
// The buyer CLI documents an AI Pay query number as 32 digits with `8282`
|
|
16
|
+
// in positions 11–14. Merchant out_trade_no values must never be accepted
|
|
17
|
+
// here: they cannot query a buyer-side 402 transaction.
|
|
18
|
+
const OUT_SHAKE_NO_PATTERN = /^\d{10}8282\d{18}$/;
|
|
19
|
+
const WALLET_SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,191}$/;
|
|
20
|
+
const OFFICIAL_WALLET_COMMAND = 'alipay-bot';
|
|
21
|
+
const REQUIRED_BUYER_PAY_OPTIONS = ['402-buyer-pay', '--session-id', '--file', '--resource-url', '--intent-summary'];
|
|
22
|
+
const REQUIRED_STATUS_OPTIONS = ['402-query-payment-status', '--out-shake-no', '--trade-no', '--resource-url'];
|
|
15
23
|
const SAFE_OFFICIAL_FAILURE_CODES = new Set([
|
|
16
24
|
'TRADE_PROGRESS_FAILED',
|
|
17
25
|
'PAYMENT_NEEDED_REJECTED',
|
|
@@ -203,6 +211,20 @@ function normalizeResourceUrl(value) {
|
|
|
203
211
|
return url.toString();
|
|
204
212
|
}
|
|
205
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Alipay support may call this a "business order number", but the 402 status
|
|
216
|
+
* API accepts it only when it has the 32-character outShakeNo shape accepted
|
|
217
|
+
* by the official client. This is a query credential, not a merchant out_trade_no and never a
|
|
218
|
+
* Payment-Proof or fulfillment acknowledgement credential.
|
|
219
|
+
*/
|
|
220
|
+
function normalizeOutShakeNo(value) {
|
|
221
|
+
const outShakeNo = String(value || '').trim();
|
|
222
|
+
if (!OUT_SHAKE_NO_PATTERN.test(outShakeNo)) {
|
|
223
|
+
throw new AlipayWalletError('ALIPAY_OUT_SHAKE_NO_INVALID', '业务单号不是有效的支付宝 AI付查询凭证。');
|
|
224
|
+
}
|
|
225
|
+
return outShakeNo;
|
|
226
|
+
}
|
|
227
|
+
|
|
206
228
|
function normalizeIntentSummary(value) {
|
|
207
229
|
const summary = safeText(value, 180);
|
|
208
230
|
if (!summary) {
|
|
@@ -211,6 +233,22 @@ function normalizeIntentSummary(value) {
|
|
|
211
233
|
return summary;
|
|
212
234
|
}
|
|
213
235
|
|
|
236
|
+
/**
|
|
237
|
+
* The official buyer CLI binds its payment intent to the active Agent
|
|
238
|
+
* conversation. A generated UUID is not a valid substitute: it prevents the
|
|
239
|
+
* wallet from correlating a later confirmation/status query with this task.
|
|
240
|
+
*/
|
|
241
|
+
function normalizeWalletSessionId(value) {
|
|
242
|
+
const sessionId = String(value || '').trim();
|
|
243
|
+
if (!sessionId) {
|
|
244
|
+
throw new AlipayWalletError('ALIPAY_SESSION_ID_REQUIRED', '付款桥接缺少当前 Agent 的业务会话 ID;未请求资源或调用钱包。');
|
|
245
|
+
}
|
|
246
|
+
if (!WALLET_SESSION_ID_PATTERN.test(sessionId)) {
|
|
247
|
+
throw new AlipayWalletError('ALIPAY_SESSION_ID_INVALID', '付款桥接的业务会话 ID 格式无效;未请求资源或调用钱包。');
|
|
248
|
+
}
|
|
249
|
+
return sessionId;
|
|
250
|
+
}
|
|
251
|
+
|
|
214
252
|
function stateDirectory(options = {}) {
|
|
215
253
|
return path.resolve(options.stateDir || process.env.XIASHE_ALIPAY_PAYMENT_STATE_DIR || DEFAULT_STATE_DIR);
|
|
216
254
|
}
|
|
@@ -282,7 +320,7 @@ function normalizedRunnerResult(result) {
|
|
|
282
320
|
}
|
|
283
321
|
|
|
284
322
|
async function runOfficialCli(args, options = {}) {
|
|
285
|
-
const command = safeText(options.command || process.env.XIASHE_ALIPAY_BOT_COMMAND ||
|
|
323
|
+
const command = safeText(options.command || process.env.XIASHE_ALIPAY_BOT_COMMAND || OFFICIAL_WALLET_COMMAND, 200);
|
|
286
324
|
if (!command || /\s/.test(command)) {
|
|
287
325
|
throw new AlipayWalletError('ALIPAY_BOT_COMMAND_INVALID', '支付宝钱包命令配置无效。');
|
|
288
326
|
}
|
|
@@ -321,13 +359,18 @@ function walletStateFromOutput(stdout, stderr) {
|
|
|
321
359
|
}
|
|
322
360
|
|
|
323
361
|
function queryCredentialFromOutput(output) {
|
|
324
|
-
//
|
|
325
|
-
//
|
|
362
|
+
// Current official CLI output labels the preferred status credential as
|
|
363
|
+
// “订单号” or “查询单号”; it is not the merchant's out_trade_no. Older
|
|
364
|
+
// wallets used camelCase/snake_case fields, which remain supported here.
|
|
365
|
+
// Match only the documented, fixed AI Pay shape and keep it in private
|
|
366
|
+
// state—never return it in bridge output.
|
|
367
|
+
const labeledOutShakeNo = output.match(/(?:订单号|查询单号)\s*(?:["::=]\s*)?["']?(\d{32})/u)?.[1] || null;
|
|
326
368
|
const tradeNo = output.match(/(?:["']?trade[\s_-]*no["']?|交易(?:流水)?号)\s*["::=]\s*["']?([A-Za-z0-9._-]{8,128})/i)?.[1] || null;
|
|
327
|
-
const
|
|
369
|
+
const structuredOutShakeNo = output.match(/(?:["']?out[\s_-]*shake[\s_-]*no["']?|外部(?:摇一摇)?编号)\s*["::=]\s*["']?(\d{32})/i)?.[1] || null;
|
|
370
|
+
const outShakeNo = labeledOutShakeNo || structuredOutShakeNo;
|
|
328
371
|
// An outShakeNo is the preferred 402 status-query credential, but only its
|
|
329
|
-
// documented 32-
|
|
330
|
-
const validOutShakeNo = outShakeNo &&
|
|
372
|
+
// documented 32-digit AI Pay form may select that query path.
|
|
373
|
+
const validOutShakeNo = outShakeNo && OUT_SHAKE_NO_PATTERN.test(outShakeNo);
|
|
331
374
|
return validOutShakeNo
|
|
332
375
|
? { type: 'out_shake_no', value: outShakeNo }
|
|
333
376
|
: tradeNo
|
|
@@ -343,9 +386,13 @@ function paymentStatusFromOutput(stdout, stderr) {
|
|
|
343
386
|
// Treat an explicit unsuccessful result as failed so callers retain the
|
|
344
387
|
// normalized failure category instead of misleadingly reporting an unknown
|
|
345
388
|
// wallet state.
|
|
346
|
-
|
|
389
|
+
// A successful status-query transport can still include the protected
|
|
390
|
+
// resource's safe ALIPAY_VERIFY_* response. That is a merchant-side proof
|
|
391
|
+
// verification failure, not an unknown wallet state.
|
|
392
|
+
const merchantDiagnostic = safeMerchantDiagnosticCode(stdout, stderr);
|
|
393
|
+
const failed = Boolean(merchantDiagnostic) || /支付失败|交易失败|payment\s+failed|"success"\s*:\s*false|"errorCode"\s*:/i.test(output);
|
|
347
394
|
return {
|
|
348
|
-
status:
|
|
395
|
+
status: failed ? 'failed' : completed ? 'completed' : pending ? 'pending_confirmation' : 'unknown',
|
|
349
396
|
credential: queryCredentialFromOutput(output),
|
|
350
397
|
diagnostic: safeDiagnostic(output)
|
|
351
398
|
};
|
|
@@ -379,6 +426,7 @@ async function officialWalletMedia(stdout, stderr) {
|
|
|
379
426
|
|
|
380
427
|
function walletFailureCode(stdout, stderr, exitCode) {
|
|
381
428
|
const output = `${stdout}\n${stderr}`;
|
|
429
|
+
if (safeMerchantDiagnosticCode(stdout, stderr)) return 'merchant_payment_proof_verification_failed';
|
|
382
430
|
if (/TRADE_PROGRESS_FAILED/i.test(output)) return 'wallet_trade_progress_failed';
|
|
383
431
|
if (/payment[_ -]?needed[_ -]?rejected|账单.*(?:被拒绝|拒绝)|bill.*(?:rejected|denied)/i.test(output)) return 'payment_needed_rejected';
|
|
384
432
|
if (/余额不足|insufficient\s+balance/i.test(output)) return 'insufficient_balance';
|
|
@@ -501,7 +549,9 @@ function paymentFailureDiagnosis(failureCode, stdout, stderr) {
|
|
|
501
549
|
|
|
502
550
|
function paymentFailureUserVisible(diagnosis) {
|
|
503
551
|
const lines = [
|
|
504
|
-
diagnosis.
|
|
552
|
+
diagnosis.stage === 'merchant_payment_proof_verification'
|
|
553
|
+
? '商家未能验证本次支付宝付款凭证,尚未授予付费 Skill 权益。'
|
|
554
|
+
: diagnosis.code === 'wallet_query_credential_missing'
|
|
505
555
|
? '支付宝钱包未返回可继续查询的交易凭据,当前付款状态无法确认。'
|
|
506
556
|
: '支付宝付款未创建可确认交易。',
|
|
507
557
|
`诊断代码:${diagnosis.code}`,
|
|
@@ -525,6 +575,9 @@ function paymentMessage(status) {
|
|
|
525
575
|
}
|
|
526
576
|
|
|
527
577
|
function paymentFailureMessage(failureCode) {
|
|
578
|
+
if (failureCode === 'merchant_payment_proof_verification_failed') {
|
|
579
|
+
return '商家未能验证支付宝付款凭证,未授予付费 Skill 权益;请勿重放旧凭证或重复付款。';
|
|
580
|
+
}
|
|
528
581
|
if (failureCode === 'wallet_query_credential_missing') {
|
|
529
582
|
return '支付宝钱包返回待确认,但未返回可继续查询的交易凭据。无法确认是否已付款,因此不会授予付费 Skill 权益;请勿再次付款。';
|
|
530
583
|
}
|
|
@@ -684,10 +737,11 @@ function publicPaymentResult(state, status, diagnostic, options = {}) {
|
|
|
684
737
|
...(failed && diagnosis ? { userVisible: paymentFailureUserVisible(diagnosis), diagnosis } : {}),
|
|
685
738
|
...(!failed && options.userVisible ? { userVisible: options.userVisible } : {}),
|
|
686
739
|
...(options.officialMedia ? { officialMedia: options.officialMedia } : {}),
|
|
740
|
+
...(options.queryCredentialSource ? { queryCredentialSource: options.queryCredentialSource } : {}),
|
|
687
741
|
...(status === 'pending_confirmation'
|
|
688
742
|
? { confirmation: { source: 'official_alipay_wallet', surface: 'app_or_official_media', required: true } }
|
|
689
743
|
: { confirmation: { source: 'official_alipay_wallet', required: false } }),
|
|
690
|
-
...(
|
|
744
|
+
...(failed ? { failureCode } : {})
|
|
691
745
|
};
|
|
692
746
|
}
|
|
693
747
|
|
|
@@ -706,17 +760,28 @@ export async function checkAlipayWallet(options = {}) {
|
|
|
706
760
|
|
|
707
761
|
/** Checks that the official buyer CLI is present, without querying wallet state. */
|
|
708
762
|
export async function getAlipayWalletBridgeInfo(options = {}) {
|
|
709
|
-
const
|
|
710
|
-
const
|
|
763
|
+
const version = await runOfficialCli(['--version'], options);
|
|
764
|
+
const buyerPayHelp = await runOfficialCli(['402-buyer-pay', '--help'], options);
|
|
765
|
+
const statusHelp = await runOfficialCli(['402-query-payment-status', '--help'], options);
|
|
766
|
+
const runtimeVersion = safeRuntimeVersion(version.stdout, version.stderr);
|
|
767
|
+
const buyerPayOutput = `${buyerPayHelp.stdout}\n${buyerPayHelp.stderr}`;
|
|
768
|
+
const statusOutput = `${statusHelp.stdout}\n${statusHelp.stderr}`;
|
|
769
|
+
const buyerPaySupported = REQUIRED_BUYER_PAY_OPTIONS.every((option) => buyerPayOutput.includes(option));
|
|
770
|
+
const statusSupported = REQUIRED_STATUS_OPTIONS.every((option) => statusOutput.includes(option));
|
|
771
|
+
const contractValid = buyerPaySupported && statusSupported;
|
|
711
772
|
return {
|
|
712
|
-
ok:
|
|
713
|
-
state: 'runtime_available',
|
|
773
|
+
ok: contractValid,
|
|
774
|
+
state: contractValid ? 'runtime_available' : 'runtime_incompatible',
|
|
714
775
|
provider: 'alipay-agent-payment',
|
|
715
|
-
message:
|
|
776
|
+
message: contractValid
|
|
777
|
+
? '已找到支付宝官方钱包程序,并确认当前买方支付/查询命令参数可用;尚未检查钱包授权或交易资格。'
|
|
778
|
+
: '支付宝官方钱包程序缺少当前 402 买方支付或查询命令参数;未创建交易。请用官方安装器更新钱包运行时后重试。',
|
|
716
779
|
walletStateChecked: false,
|
|
717
780
|
transactionCreationVerified: false,
|
|
781
|
+
paymentCommandsValidated: contractValid,
|
|
718
782
|
...(runtimeVersion ? { runtimeVersion } : {}),
|
|
719
|
-
|
|
783
|
+
...(contractValid ? {} : { failureCode: 'wallet_runtime_contract_invalid' }),
|
|
784
|
+
diagnostic: safeDiagnostic(`${version.stdout}\n${version.stderr}\n${buyerPayHelp.stdout}\n${buyerPayHelp.stderr}\n${statusHelp.stdout}\n${statusHelp.stderr}`)
|
|
720
785
|
};
|
|
721
786
|
}
|
|
722
787
|
|
|
@@ -732,6 +797,7 @@ export async function payAlipay402Resource(input, options = {}) {
|
|
|
732
797
|
}
|
|
733
798
|
const resourceUrl = normalizeResourceUrl(input?.resourceUrl);
|
|
734
799
|
const intentSummary = normalizeIntentSummary(input?.intentSummary);
|
|
800
|
+
const sessionId = normalizeWalletSessionId(input?.sessionId);
|
|
735
801
|
const resource = await fetchPaymentNeeded(resourceUrl, options);
|
|
736
802
|
if (resource.alreadyPaid) {
|
|
737
803
|
const state = (await readPaymentState(resourceUrl, options)) || {
|
|
@@ -758,7 +824,7 @@ export async function payAlipay402Resource(input, options = {}) {
|
|
|
758
824
|
version: 1,
|
|
759
825
|
resourceUrl,
|
|
760
826
|
resourceDigest: digest(resourceUrl),
|
|
761
|
-
sessionId
|
|
827
|
+
sessionId,
|
|
762
828
|
paymentNeededPath: paths.paymentNeededPath,
|
|
763
829
|
queryCredential: null,
|
|
764
830
|
merchantResource: {
|
|
@@ -774,9 +840,9 @@ export async function payAlipay402Resource(input, options = {}) {
|
|
|
774
840
|
const walletInvocation = await runOfficialCli([
|
|
775
841
|
'402-buyer-pay',
|
|
776
842
|
// The official buyer CLI accepts this long form of -s/-f/-r. The file
|
|
777
|
-
// name is intentionally private and random
|
|
778
|
-
// content, original resource URL, and
|
|
779
|
-
// define the payment request.
|
|
843
|
+
// name is intentionally private and random. The exact Payment-Needed
|
|
844
|
+
// content, original resource URL, and active Agent business session are
|
|
845
|
+
// what define the payment request.
|
|
780
846
|
'--session-id', state.sessionId,
|
|
781
847
|
'--file', state.paymentNeededPath,
|
|
782
848
|
'--resource-url', resourceUrl,
|
|
@@ -821,11 +887,25 @@ export async function payAlipay402Resource(input, options = {}) {
|
|
|
821
887
|
});
|
|
822
888
|
}
|
|
823
889
|
|
|
824
|
-
/**
|
|
890
|
+
/**
|
|
891
|
+
* Queries an existing local payment attempt using its official query
|
|
892
|
+
* credential. Alipay support can also provide a valid outShakeNo (often
|
|
893
|
+
* called a business order number); that one read-only query does not create
|
|
894
|
+
* local state, initiate a payment, or acknowledge fulfillment.
|
|
895
|
+
*/
|
|
825
896
|
export async function queryAlipay402Resource(input, options = {}) {
|
|
826
897
|
const resourceUrl = normalizeResourceUrl(input?.resourceUrl);
|
|
827
|
-
const
|
|
828
|
-
|
|
898
|
+
const suppliedOutShakeNo = input?.outShakeNo || input?.businessNo
|
|
899
|
+
? normalizeOutShakeNo(input?.outShakeNo || input?.businessNo)
|
|
900
|
+
: null;
|
|
901
|
+
if (input?.outShakeNo && input?.businessNo && normalizeOutShakeNo(input.outShakeNo) !== normalizeOutShakeNo(input.businessNo)) {
|
|
902
|
+
throw new AlipayWalletError('ALIPAY_OUT_SHAKE_NO_CONFLICT', '提供的支付宝查询业务单号不一致。');
|
|
903
|
+
}
|
|
904
|
+
const persistedState = await readPaymentState(resourceUrl, options);
|
|
905
|
+
const queryCredential = suppliedOutShakeNo
|
|
906
|
+
? { type: 'out_shake_no', value: suppliedOutShakeNo }
|
|
907
|
+
: persistedState?.queryCredential;
|
|
908
|
+
if (!queryCredential?.value) {
|
|
829
909
|
return {
|
|
830
910
|
ok: false,
|
|
831
911
|
status: 'no_active_payment',
|
|
@@ -836,9 +916,20 @@ export async function queryAlipay402Resource(input, options = {}) {
|
|
|
836
916
|
diagnostic: 'payment_state_not_found'
|
|
837
917
|
};
|
|
838
918
|
}
|
|
839
|
-
const
|
|
840
|
-
|
|
841
|
-
|
|
919
|
+
const state = persistedState || {
|
|
920
|
+
version: 1,
|
|
921
|
+
resourceUrl,
|
|
922
|
+
resourceDigest: digest(resourceUrl),
|
|
923
|
+
sessionId: null,
|
|
924
|
+
paymentNeededPath: null,
|
|
925
|
+
queryCredential: null,
|
|
926
|
+
status: 'querying',
|
|
927
|
+
createdAt: new Date().toISOString(),
|
|
928
|
+
updatedAt: new Date().toISOString()
|
|
929
|
+
};
|
|
930
|
+
const queryArg = queryCredential.type === 'out_shake_no'
|
|
931
|
+
? ['--out-shake-no', queryCredential.value]
|
|
932
|
+
: ['--trade-no', queryCredential.value];
|
|
842
933
|
const walletInvocation = await runOfficialCli([
|
|
843
934
|
'402-query-payment-status',
|
|
844
935
|
...queryArg,
|
|
@@ -851,20 +942,24 @@ export async function queryAlipay402Resource(input, options = {}) {
|
|
|
851
942
|
const officialMedia = ['pending_confirmation', 'completed'].includes(status)
|
|
852
943
|
? await officialWalletMedia(walletInvocation.stdout, walletInvocation.stderr)
|
|
853
944
|
: null;
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
:
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
945
|
+
const failureCode = failed ? walletFailureCode(walletInvocation.stdout, walletInvocation.stderr, walletInvocation.exitCode) : undefined;
|
|
946
|
+
if (persistedState) {
|
|
947
|
+
state.status = status;
|
|
948
|
+
// A support-supplied outShakeNo is used only for this invocation. Never
|
|
949
|
+
// replace or persist a local query credential with it.
|
|
950
|
+
state.queryCredential = suppliedOutShakeNo ? state.queryCredential : result.credential || state.queryCredential;
|
|
951
|
+
state.lastFailureCode = failureCode ?? null;
|
|
952
|
+
state.lastDiagnosis = failed
|
|
953
|
+
? paymentFailureDiagnosis(failureCode, walletInvocation.stdout, walletInvocation.stderr)
|
|
954
|
+
: null;
|
|
955
|
+
state.lastWalletDiagnostic = result.diagnostic;
|
|
956
|
+
state.updatedAt = new Date().toISOString();
|
|
957
|
+
await writePaymentState(state, options);
|
|
958
|
+
}
|
|
959
|
+
if (status === 'completed' && persistedState) {
|
|
864
960
|
const { paymentNeededPath } = statePaths(resourceUrl, options);
|
|
865
961
|
await rm(paymentNeededPath, { force: true });
|
|
866
962
|
}
|
|
867
|
-
const failureCode = failed ? state.lastFailureCode : undefined;
|
|
868
963
|
return publicPaymentResult(state, status, result.diagnostic, {
|
|
869
964
|
commandFailed: failed,
|
|
870
965
|
failureCode,
|
|
@@ -872,7 +967,8 @@ export async function queryAlipay402Resource(input, options = {}) {
|
|
|
872
967
|
userVisible: ['pending_confirmation', 'completed'].includes(status)
|
|
873
968
|
? userVisibleWalletOutput(walletInvocation.stdout, walletInvocation.stderr)
|
|
874
969
|
: null,
|
|
875
|
-
officialMedia
|
|
970
|
+
officialMedia,
|
|
971
|
+
...(suppliedOutShakeNo ? { queryCredentialSource: 'supplied_out_shake_no' } : {})
|
|
876
972
|
});
|
|
877
973
|
}
|
|
878
974
|
|
package/index.mjs
CHANGED