diff --git a/docs/douyin-pay-integration.md b/docs/douyin-pay-integration.md index 4f2fda1..7283b6e 100644 --- a/docs/douyin-pay-integration.md +++ b/docs/douyin-pay-integration.md @@ -127,3 +127,28 @@ HBuilderX 5.24 App 资源编译及导出成功,仅有既有 CSS Autoprefixer 39 项前端测试通过,包含杀进程后新运行上下文恢复、取消/原生错误、离线恢复、失败跳转重试、迟到成功及微信/支付宝失败页回归。 本次只改 JavaScript、测试和文档,不改后端、原生插件、SQL、接口或页面样式。尚未真机实付。 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`,避免生成依赖较新 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、支付接口或商户配置变更。 diff --git a/package.json b/package.json index cdfb4b0..3305305 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "version": "1.0.2", "description": "信任桥", "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": [ "tb", diff --git a/tests/compile-douyin-android.ps1 b/tests/compile-douyin-android.ps1 new file mode 100644 index 0000000..1d0df88 --- /dev/null +++ b/tests/compile-douyin-android.ps1 @@ -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.' diff --git a/tests/douyin-uts.test.cjs b/tests/douyin-uts.test.cjs new file mode 100644 index 0000000..1c810a9 --- /dev/null +++ b/tests/douyin-uts.test.cjs @@ -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); + } +}); diff --git a/uni_modules/tb-douyin-pay/package.json b/uni_modules/tb-douyin-pay/package.json index b019b2a..f83d5b0 100644 --- a/uni_modules/tb-douyin-pay/package.json +++ b/uni_modules/tb-douyin-pay/package.json @@ -1,7 +1,7 @@ { "id": "tb-douyin-pay", "displayName": "TB 抖音支付", - "version": "0.2.0", + "version": "0.2.1", "description": "独立实现的抖音支付官方 SDK UTS 桥接", "engines": { "HBuilderX": ">=4.36.0" }, "uni_modules": { diff --git a/uni_modules/tb-douyin-pay/utssdk/common.uts b/uni_modules/tb-douyin-pay/utssdk/common.uts index e25dda4..7288f1d 100644 --- a/uni_modules/tb-douyin-pay/utssdk/common.uts +++ b/uni_modules/tb-douyin-pay/utssdk/common.uts @@ -1,4 +1,4 @@ -import { PayOptions, PayCallback, PayResult } from './interface.uts' +import { PayInfo, PayOptions, PayCallback, PayResult } from './interface.uts' let appId = '' let busy = false @@ -24,12 +24,25 @@ export function invoke(options: PayOptions, callback: PayCallback, return } let valid = false + let payload: string = '' try { - const info = options.payInfo - const values = [info.appid, info.partnerid, info.prepayid, info.package, info.noncestr, info.timestamp, info.sign] - valid = appId.length > 0 && info.appid == appId && info.package == 'Sign=DYPay' - 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 + // Swift infers an optional from the generated PayInfo! property unless + // the local type is explicit. Check before narrowing on both platforms. + const candidate: PayInfo | null = options.payInfo + if (candidate != null) { + const info: PayInfo = candidate as PayInfo + const values = new Array(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 }) + 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 } if (!valid) { const result: PayResult = { resultCode: '-1', errorMsg: '支付参数不完整或 AppID 不匹配,请重新下单', needsQuery: false } @@ -61,7 +74,7 @@ export function invoke(options: PayOptions, callback: PayCallback, callback(result) } try { - nativePay(JSON.stringify(options.payInfo), options.showLoading ?? true, complete) + nativePay(payload, options.showLoading ?? true, complete) } catch (e) { complete('{"resultCode":"2","errorMsg":"无法调用支付 SDK,请查询订单后重试"}') }