fix: 修复抖音支付双端UTS编译兼容
This commit is contained in:
@@ -127,3 +127,28 @@ HBuilderX 5.24 App 资源编译及导出成功,仅有既有 CSS Autoprefixer
|
|||||||
39 项前端测试通过,包含杀进程后新运行上下文恢复、取消/原生错误、离线恢复、失败跳转重试、迟到成功及微信/支付宝失败页回归。
|
39 项前端测试通过,包含杀进程后新运行上下文恢复、取消/原生错误、离线恢复、失败跳转重试、迟到成功及微信/支付宝失败页回归。
|
||||||
本次只改 JavaScript、测试和文档,不改后端、原生插件、SQL、接口或页面样式。尚未真机实付。
|
本次只改 JavaScript、测试和文档,不改后端、原生插件、SQL、接口或页面样式。尚未真机实付。
|
||||||
HBuilderX 5.24 App 资源编译与导出成功,只有既有 CSS Autoprefixer 注释警告。
|
HBuilderX 5.24 App 资源编译与导出成功,只有既有 CSS Autoprefixer 注释警告。
|
||||||
|
|
||||||
|
## 双端 UTS 编译兼容(2026-09-10,插件 0.2.1)
|
||||||
|
|
||||||
|
基于用户确认的远端 `dev_codex` / `3e2e182`,不改变支付页面或后端逻辑。
|
||||||
|
|
||||||
|
- 修复 `common.uts` 在 iOS 生成 Swift 时局部变量被推断为 `PayInfo?`:先判空,再明确声明 `PayInfo`;字段值先收集为非空字符串后验证,不修改签名原文。
|
||||||
|
- `JSON.stringify` 的可空返回值在加锁前处理;序列化失败直接结束,不把 `String?` 传入原生支付接口,也不会占用支付锁。
|
||||||
|
- 使用显式 `new Array<string>`,避免生成依赖较新 Android 运行时的 `_uA` 数组辅助函数,同时让 Swift 元素类型明确。
|
||||||
|
- Android 与 iOS 共用修复。Android 的 DyPay 类及方法已通过官方 Maven `com.bytedance.caijing:dy-pay-sdk-tob:1.1.0.9` 的真实 classes.jar 核验,不改 SDK 包名、不使用反射绕过错误。
|
||||||
|
|
||||||
|
验证:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 使用实际安装路径;资源导出与 Kotlin 依赖可以来自不同安装目录。
|
||||||
|
$env:HBUILDERX_HOME = '你的 HBuilderX 安装目录'
|
||||||
|
npm run test:douyin
|
||||||
|
npm run test:douyin:uts
|
||||||
|
# 先通过 HBuilderX 导出 APP 资源,再使用已安装 Android UTS 扩展的目录。
|
||||||
|
# SdkJar 是从上述官方 AAR 中解压出的 classes.jar。
|
||||||
|
./tests/compile-douyin-android.ps1 -HBuilderXHome '含 Android UTS 扩展的 HBuilderX 目录' -SdkJar 'SDK classes.jar 的绝对路径'
|
||||||
|
```
|
||||||
|
|
||||||
|
本地结果:39 项页面/业务回归 + 5 项实际共享 UTS 逻辑测试全部通过;HBuilderX 5.24 App 资源导出通过;生成的 Kotlin 与原始 `TbDouyinPayNative.kt` 使用真实 Android/UTS/DyPay 依赖编译成功。Kotlin 中跨平台空值兜底产生的“左侧非空”提示是警告。
|
||||||
|
|
||||||
|
验证边界:UTS 逻辑测试转译为 JS,不等于原生测试;Kotlin 编译不等于完整 APK 打包。iOS 已检查生成 Swift 的 `PayInfo` 和 `String` 类型,但 Windows 无 Xcode,未完成 IPA 原生编译或真机交易。仍须重新云打包 Android/iOS,验收正常支付、取消、杀进程恢复及成功/失败跳转。插件 0.2.1 必须随完整 APK/IPA 或自定义基座发布,不能仅用 WGT 更新旧插件。无后端、SQL、支付接口或商户配置变更。
|
||||||
|
|||||||
+2
-1
@@ -5,7 +5,8 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"description": "信任桥",
|
"description": "信任桥",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test:douyin": "node --test tests/douyin-integration.test.cjs"
|
"test:douyin": "node --test tests/douyin-integration.test.cjs",
|
||||||
|
"test:douyin:uts": "node --test tests/douyin-uts.test.cjs"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"tb",
|
"tb",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$HBuilderXHome,
|
||||||
|
[Parameter(Mandatory = $true)][string]$SdkJar
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
# Compile the generated UTS and the original native bridge against real SDKs.
|
||||||
|
# First export App resources using HBuilderX. SdkJar is classes.jar extracted
|
||||||
|
# from the official dy-pay-sdk-tob 1.1.0.9 AAR, not a mock or reflection shim.
|
||||||
|
$projectDir = Split-Path -Parent $PSScriptRoot
|
||||||
|
$runtimeDir = Join-Path $HBuilderXHome 'plugins/uniapp-runextension'
|
||||||
|
$compilerDir = Join-Path $runtimeDir 'kotlinc'
|
||||||
|
$generated = Join-Path $projectDir 'unpackage/resources/uni_modules/tb-douyin-pay/utssdk/app-android/src/index.kt'
|
||||||
|
$native = Join-Path $projectDir 'uni_modules/tb-douyin-pay/utssdk/app-android/TbDouyinPayNative.kt'
|
||||||
|
foreach ($required in @($SdkJar, $generated, $native, (Join-Path $compilerDir 'lib/kotlin-compiler.jar'))) {
|
||||||
|
if (!(Test-Path -LiteralPath $required)) { throw "Missing dependency or source: $required" }
|
||||||
|
}
|
||||||
|
$taskClasspath = ((Get-ChildItem -LiteralPath (Join-Path $runtimeDir 'lib') -Filter '*.jar').FullName + @((Resolve-Path -LiteralPath $SdkJar).Path)) -join ';'
|
||||||
|
$resultDir = New-Item -ItemType Directory -Path (Join-Path ([IO.Path]::GetTempPath()) ('tb-douyin-kotlin-' + [guid]::NewGuid().ToString('N')))
|
||||||
|
$resultJar = Join-Path $resultDir.FullName 'tb-douyin-pay.jar'
|
||||||
|
& java -cp (Join-Path $compilerDir 'lib/*') org.jetbrains.kotlin.cli.jvm.K2JVMCompiler `
|
||||||
|
-kotlin-home $compilerDir $generated $native -classpath $taskClasspath -d $resultJar
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Kotlin compilation failed: $LASTEXITCODE" }
|
||||||
|
Write-Output "Kotlin compilation passed: $resultJar"
|
||||||
|
Write-Output 'This check does not build/sign an APK or execute a payment.'
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// Exercises the actual shared UTS logic as JS, not a duplicate implementation.
|
||||||
|
// This does not replace Kotlin/Swift native compilation or device tests.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const hx = process.env.HBUILDERX_HOME;
|
||||||
|
assert.ok(hx, 'Set HBUILDERX_HOME to the HBuilderX installation directory');
|
||||||
|
const ts = require(path.join(hx, 'plugins/uniapp-uts-v1/node_modules/@dcloudio/uni-uts-v1/lib/typescript/lib/typescript.js'));
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '../uni_modules/tb-douyin-pay/utssdk/common.uts'), 'utf8');
|
||||||
|
const compiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS },
|
||||||
|
}).outputText;
|
||||||
|
const info = { appid: 'app', partnerid: 'merchant', prepayid: 'prepay', package: 'Sign=DYPay',
|
||||||
|
noncestr: 'nonce', timestamp: '1780000000', sign: 'signed+value/==' };
|
||||||
|
function load(stringify = JSON.stringify) {
|
||||||
|
const timers = new Map();
|
||||||
|
let timerId = 0;
|
||||||
|
const exports = {};
|
||||||
|
const ctx = { exports, JSON: { stringify, parseObject(raw) {
|
||||||
|
const value = JSON.parse(raw);
|
||||||
|
return value == null ? null : { getString: key => typeof value[key] === 'string' ? value[key] : null };
|
||||||
|
} }, setTimeout(fn) { timers.set(++timerId, fn); return timerId; },
|
||||||
|
clearTimeout(id) { timers.delete(id); } };
|
||||||
|
vm.runInNewContext(compiled, ctx);
|
||||||
|
assert.equal(exports.configure('app'), true);
|
||||||
|
return { ...exports, timers };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shared bridge keeps signed fields unchanged and handles default/explicit loading', () => {
|
||||||
|
for (const showLoading of [undefined, null, true, false]) {
|
||||||
|
const bridge = load();
|
||||||
|
const results = [];
|
||||||
|
bridge.invoke({ payInfo: info, showLoading }, result => results.push(result), (payload, loading, done) => {
|
||||||
|
assert.deepEqual(JSON.parse(payload), info);
|
||||||
|
assert.equal(loading, showLoading ?? true);
|
||||||
|
done('{"resultCode":"0","errorMsg":""}');
|
||||||
|
});
|
||||||
|
assert.equal(results.length, 1);
|
||||||
|
assert.equal(results[0].resultCode, '0');
|
||||||
|
assert.equal(results[0].needsQuery, true);
|
||||||
|
assert.equal(bridge.timers.size, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing/null/empty payment fields never reach native code or take a lock', () => {
|
||||||
|
const invalid = [null, undefined, {}, { ...info, appid: 'wrong' },
|
||||||
|
{ ...info, package: 'wrong' }, { ...info, timestamp: '1780000000000' },
|
||||||
|
{ ...info, noncestr: 'n'.repeat(33) }];
|
||||||
|
for (const key of Object.keys(info)) {
|
||||||
|
for (const value of [null, undefined, '', ' ']) invalid.push({ ...info, [key]: value });
|
||||||
|
}
|
||||||
|
for (const payInfo of invalid) {
|
||||||
|
const bridge = load();
|
||||||
|
const results = [];
|
||||||
|
bridge.invoke({ payInfo }, r => results.push(r), () => assert.fail('Invalid native call'));
|
||||||
|
assert.equal(results.length, 1);
|
||||||
|
assert.equal(results[0].resultCode, '-1');
|
||||||
|
assert.equal(bridge.timers.size, 0);
|
||||||
|
assert.equal(bridge.configure('app'), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null or failed serialization returns an error before the lock and native call', () => {
|
||||||
|
for (const stringify of [() => null, () => undefined, () => '', () => { throw Error('serialize'); }]) {
|
||||||
|
const bridge = load(stringify);
|
||||||
|
bridge.invoke({ payInfo: info }, r => assert.equal(r.resultCode, '-1'), () => assert.fail('Invalid payload'));
|
||||||
|
assert.equal(bridge.timers.size, 0);
|
||||||
|
assert.equal(bridge.configure('app'), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reentrant calls, duplicate callbacks and late callbacks after reset are isolated', () => {
|
||||||
|
const bridge = load();
|
||||||
|
const results = [];
|
||||||
|
let oldDone;
|
||||||
|
bridge.invoke({ payInfo: info }, r => results.push(r), (_, __, done) => { oldDone = done; });
|
||||||
|
bridge.invoke({ payInfo: info }, r => assert.equal(r.resultCode, '103'), () => assert.fail('Duplicate pay'));
|
||||||
|
assert.equal(bridge.configure('other'), false);
|
||||||
|
bridge.resetPending();
|
||||||
|
let newDone;
|
||||||
|
bridge.invoke({ payInfo: info }, r => results.push(r), (_, __, done) => { newDone = done; });
|
||||||
|
oldDone('{"resultCode":"0"}');
|
||||||
|
assert.equal(results.length, 0);
|
||||||
|
newDone('{"resultCode":"1"}');
|
||||||
|
newDone('{"resultCode":"0"}');
|
||||||
|
assert.equal(results.length, 1);
|
||||||
|
assert.equal(results[0].resultCode, '1');
|
||||||
|
for (const timer of bridge.timers.values()) timer();
|
||||||
|
assert.equal(results.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timeout, malformed result and native exception remain query-required and unlock', () => {
|
||||||
|
for (const mode of ['timeout', 'malformed', 'throw']) {
|
||||||
|
const bridge = load();
|
||||||
|
const results = [];
|
||||||
|
bridge.invoke({ payInfo: info }, r => results.push(r), (_, __, done) => {
|
||||||
|
if (mode === 'throw') throw Error('native');
|
||||||
|
if (mode === 'malformed') done('{');
|
||||||
|
});
|
||||||
|
if (mode === 'timeout') for (const timer of bridge.timers.values()) timer();
|
||||||
|
assert.equal(results.length, 1);
|
||||||
|
assert.notEqual(results[0].resultCode, '0');
|
||||||
|
assert.equal(results[0].needsQuery, true);
|
||||||
|
assert.equal(bridge.configure('app'), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "tb-douyin-pay",
|
"id": "tb-douyin-pay",
|
||||||
"displayName": "TB 抖音支付",
|
"displayName": "TB 抖音支付",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"description": "独立实现的抖音支付官方 SDK UTS 桥接",
|
"description": "独立实现的抖音支付官方 SDK UTS 桥接",
|
||||||
"engines": { "HBuilderX": ">=4.36.0" },
|
"engines": { "HBuilderX": ">=4.36.0" },
|
||||||
"uni_modules": {
|
"uni_modules": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PayOptions, PayCallback, PayResult } from './interface.uts'
|
import { PayInfo, PayOptions, PayCallback, PayResult } from './interface.uts'
|
||||||
|
|
||||||
let appId = ''
|
let appId = ''
|
||||||
let busy = false
|
let busy = false
|
||||||
@@ -24,12 +24,25 @@ export function invoke(options: PayOptions, callback: PayCallback,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let valid = false
|
let valid = false
|
||||||
|
let payload: string = ''
|
||||||
try {
|
try {
|
||||||
const info = options.payInfo
|
// Swift infers an optional from the generated PayInfo! property unless
|
||||||
const values = [info.appid, info.partnerid, info.prepayid, info.package, info.noncestr, info.timestamp, info.sign]
|
// the local type is explicit. Check before narrowing on both platforms.
|
||||||
valid = appId.length > 0 && info.appid == appId && info.package == 'Sign=DYPay'
|
const candidate: PayInfo | null = options.payInfo
|
||||||
|
if (candidate != null) {
|
||||||
|
const info: PayInfo = candidate as PayInfo
|
||||||
|
const values = new Array<string>(info.appid ?? '', info.partnerid ?? '', info.prepayid ?? '',
|
||||||
|
info.package ?? '', info.noncestr ?? '', info.timestamp ?? '', info.sign ?? '')
|
||||||
|
valid = appId.length > 0 && values[0] == appId && values[3] == 'Sign=DYPay'
|
||||||
values.forEach((value: string) => { if (value.trim().length == 0) valid = false })
|
values.forEach((value: string) => { if (value.trim().length == 0) valid = false })
|
||||||
if (!/^[0-9]{1,10}$/.test(info.timestamp) || info.noncestr.length > 32) valid = false
|
if (!/^[0-9]{1,10}$/.test(values[5]) || values[4].length > 32) valid = false
|
||||||
|
// UTS JSON.stringify returns string | null. Serialize before taking the
|
||||||
|
// payment lock, and never pass a nullable value to a native String API.
|
||||||
|
if (valid) {
|
||||||
|
payload = JSON.stringify(info) ?? ''
|
||||||
|
if (payload.length == 0) valid = false
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) { valid = false }
|
} catch (e) { valid = false }
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
const result: PayResult = { resultCode: '-1', errorMsg: '支付参数不完整或 AppID 不匹配,请重新下单', needsQuery: false }
|
const result: PayResult = { resultCode: '-1', errorMsg: '支付参数不完整或 AppID 不匹配,请重新下单', needsQuery: false }
|
||||||
@@ -61,7 +74,7 @@ export function invoke(options: PayOptions, callback: PayCallback,
|
|||||||
callback(result)
|
callback(result)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
nativePay(JSON.stringify(options.payInfo), options.showLoading ?? true, complete)
|
nativePay(payload, options.showLoading ?? true, complete)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
complete('{"resultCode":"2","errorMsg":"无法调用支付 SDK,请查询订单后重试"}')
|
complete('{"resultCode":"2","errorMsg":"无法调用支付 SDK,请查询订单后重试"}')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user