fix: 修复抖音支付双端UTS编译兼容

This commit is contained in:
Codex
2026-09-10 10:57:53 +08:00
parent 3e2e1829a8
commit e692f3d9b1
6 changed files with 180 additions and 9 deletions
+25
View File
@@ -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
View File
@@ -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",
+24
View File
@@ -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.'
+108
View File
@@ -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 -1
View File
@@ -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": {
+19 -6
View File
@@ -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,请查询订单后重试"}')
} }