diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f2c8b34..de6a059 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + @@ -49,5 +50,9 @@ android:exported="false" android:foregroundServiceType="dataSync" android:stopWithTask="false" /> + + diff --git a/android/app/src/main/java/com/rnpay/MainActivity.java b/android/app/src/main/java/com/rnpay/MainActivity.java index db188a0..7c1f739 100644 --- a/android/app/src/main/java/com/rnpay/MainActivity.java +++ b/android/app/src/main/java/com/rnpay/MainActivity.java @@ -22,6 +22,7 @@ public class MainActivity extends ReactActivity { @Override protected void onCreate(android.os.Bundle savedInstanceState) { super.onCreate(savedInstanceState); + ProxyFcmService.handleLaunchIntent(this); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { diff --git a/docs/walletman前端对接.md b/docs/walletman前端对接.md new file mode 100644 index 0000000..559a3af --- /dev/null +++ b/docs/walletman前端对接.md @@ -0,0 +1,305 @@ +# WalletMan 前端对接文档 + +> 后端协议见 [walletman后端对接.md](./walletman后端对接.md) + +宿主 App(如 **rnpay**)依赖 **rnwalletman**,负责 Proxy 常驻、WS 连接、FCM 收令、绑钱包 UI。 + +--- + +## 1. 职责 + +| 模块 | 职责 | +|------|------| +| `ProxyBackgroundService` | 启停 Proxy、WS 状态、FCM/rebind 配置 | +| `BaseProxyService` | 前台 Service、WS、TCP 代理、FCM 静默重绑 | +| `ProxyFcmService` | 收 FCM、拉活、通知点击 → prefs → JS | +| 宿主 `*ProxyService` | 实现 `registerWallet` → POST `/register` | +| Bind 组件 | 各钱包 token/OTP 绑定 UI | + +**clientId:** `DeviceInfo.getUniqueIdSync()`,与后端 WS/FCM 一致。 + +--- + +## 2. 依赖 + +```json +"rnwalletman": "file:libs/rnwalletman" +``` + +改 lib 后: + +```bash +rsync -a --delete libs/rnwalletman/ node_modules/rnwalletman/ +``` + +需 Firebase:`google-services.json` 与服务端 FCM 同项目。 + +--- + +## 3. Android 配置 + +### Application + +```java +// MainApplication.onCreate +BaseProxyService.setServiceClass(RnpayProxyService.class); +``` + +### Manifest + +```xml + + + +``` + +`ProxyFcmService` 由 rnwalletman manifest merge,宿主不用重复声明。 + +### MainActivity + +```java +@Override protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ProxyFcmService.handleLaunchIntent(this); +} +@Override protected void onResume() { + super.onResume(); + ProxyFcmService.handleLaunchIntent(this); +} +@Override public void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + ProxyFcmService.handleLaunchIntent(this); +} +``` + +`launchMode="singleTask"` 推荐。 + +--- + +## 4. 宿主 registerWallet + +FCM 静默重绑成功后 native 调用: + +```java +@Override +protected void registerWallet(Context ctx, String walletId, String walletType, + String phone, JSONObject params) throws Exception { + // POST {baseUrl}/register + // body: { "walletType": walletType, "params": params } + // header: X-User-ID +} +``` + +`baseUrl/userId/userToken` 来自 JS `syncRebindConfig` → `proxy_service_prefs`。 + +参考:`android/app/src/main/java/com/rnpay/RnpayProxyService.java` + +--- + +## 5. JS 对接 + +### 启动顺序 + +```typescript +import { + proxyBackgroundService, + type RebindConfig, + type PatchConfig, + type FcmNotificationTapPayload, +} from 'rnwalletman'; +import DeviceInfo from 'react-native-device-info'; + +const PATCH_EXPECT: PatchConfig = { + chType: 'ipay', + paytmChVersion: '3', + phonepeChVersion: '1', + mobikwikChVersion: '1', +}; + +async function bootstrap(userId: number, userToken: string) { + const clientId = DeviceInfo.getUniqueIdSync(); + + // ① 尽早注册(componentDidMount 第一行) + proxyBackgroundService.setNotificationTapHandler(handleNotificationTap); + + await proxyBackgroundService.syncPatchConfig(PATCH_EXPECT); + + await proxyBackgroundService.syncRebindConfig({ + baseUrl: Api.BASE_URL, + userId, + userToken, + onRebound: () => fetchWallets(), + }); + + await proxyBackgroundService.start({ + wsUrl: Api.WS_URL, + clientId, + userId, + heartbeatInterval: 10000, + reconnectInterval: 5000, + registerFcmToken: (cid, token) => + Api.instance.registerFcmToken(cid, token), + onConnected: () => setProxyStatus('connected'), + onDisconnected: () => setProxyStatus('disconnected'), + onError: (msg) => setProxyStatus('error', msg), + }); +} + +function handleNotificationTap(payload: FcmNotificationTapPayload) { + if (payload.cmd !== 'rebind_wallet') return; + const { walletId, walletType, phone } = payload.params; + // 按 walletType 打开 Bind 弹窗 +} +``` + +App 回前台时补读 pending: + +```typescript +AppState.addEventListener('change', (s) => { + if (s === 'active') void proxyBackgroundService.syncPendingNotificationTap(); +}); +``` + +### API + +| 方法 | 作用 | +|------|------| +| `start(config)` | 前台 Proxy + WS | +| `stop()` | 停止 | +| `syncRebindConfig(config \| null)` | native 重绑 HTTP 配置 | +| `syncPatchConfig(config)` | ipay chType/chVersion 校验 | +| `setNotificationTapHandler(fn \| null)` | 通知点击回调 | +| `syncPendingNotificationTap()` | 消费 prefs pending | + +### Native 事件 + +| 事件 | data | +|------|------| +| ProxyServiceConnected | - | +| ProxyServiceDisconnected | - | +| ProxyServiceRebound | walletId | +| ProxyServiceNotificationTap | JSON `{cmd,params}` | +| ProxyServiceCustomCommand | WS 自定义消息 | + +--- + +## 6. 绑钱包 + +**Token 模式** + +1. 打开 `PaytmPersonalBind` / `PhonePePersonalBind` 等 +2. 校验 `chType/chVersion` === `PATCH_EXPECT` +3. `POST /register` + +**OTP 模式** + +`POST /request-otp` → `POST /verify-otp` + +**HTTP 封装参考:** `services/api.ts` + +--- + +## 7. FCM 客户端行为 + +### 收到 FCM + +| cmd | 行为 | +|-----|------| +| wake_proxy | `BaseProxyService.wakeFromPrefs()` | +| rebind_wallet | 先静默 AIDL → 成功 `registerWallet`;失败弹本地通知 | + +### 点击通知 + +``` +PendingIntent(cmd+params) + → MainActivity onCreate/onNewIntent/onResume + → handleLaunchIntent 读 extra + → 写 prefs pending_notif_tap + → emit 或等 JS syncPendingNotificationTap + → setNotificationTapHandler 回调 +``` + +Intent 负责拉起 App;prefs 解决 JS 未 ready 的时序问题。 + +--- + +## 8. 时序(客户端视角) + +**冷启动** + +``` +componentDidMount + → setNotificationTapHandler + → login → syncPatch/Rebind → start + → WS register { clientId, userId, fcmToken } + → POST /fcm/register + → GET /wallets +``` + +**代理** + +``` +收到 proxyRequest + → TCP host:port + → proxyReady → proxyData 双向 +``` + +**重绑** + +``` +FCM rebind_wallet + → 静默 AIDL → registerWallet → POST /register → ProxyServiceRebound + → 失败 → 本地通知 → 点击 → handleNotificationTap → 打开 Bind +``` + +--- + +## 9. 调试 + +```bash +adb logcat -s ProxyFcmService ProxyService WalletRebind ProxyServiceModule +``` + +```bash +adb shell run-as com.rnpay cat shared_prefs/proxy_service_prefs.xml | grep pending +``` + +| 现象 | 排查 | +|------|------| +| FCM 无日志 | manifest 去掉默认 MessagingService;勿 force-stop | +| 通知点击无回调 | logcat `notification tap`;handler 是否第一行注册 | +| Proxy Connecting 卡住 | 重启后 WS 已连但未 emit Connected(已修复 sync) | +| version outdated | PATCH_EXPECT 与 ipay 包不一致 | +| 代理失败 | WS 是否 connected;等 wake 后再试 | + +服务端测试页可发 wake/rebind:`http://server:16000/test/index.html` + +--- + +## 10. rnpay 参考 + +| 文件 | 职责 | +|------|------| +| `services/api.ts` | BASE_URL / WS_URL / HTTP | +| `screens/HomeScreen.tsx` | Proxy、PATCH_EXPECT、notificationTap | +| `RnpayProxyService.java` | registerWallet | +| `MainApplication.java` | setServiceClass | +| `MainActivity.java` | handleLaunchIntent | + +--- + +## 11. 新宿主 checklist + +1. 依赖 rnwalletman + Firebase +2. `setServiceClass` + Manifest Service + 去默认 FCM Service +3. MainActivity 处理 launch intent +4. 继承 `BaseProxyService` 实现 `registerWallet` +5. JS:`setNotificationTapHandler` → `syncPatch` → `syncRebind` → `start` +6. 各钱包 Bind UI + `POST /register` diff --git a/docs/walletman后端对接.md b/docs/walletman后端对接.md new file mode 100644 index 0000000..45f662a --- /dev/null +++ b/docs/walletman后端对接.md @@ -0,0 +1,309 @@ +# WalletMan 后端对接文档 + +> 前端对接见 [walletman前端对接.md](./walletman前端对接.md) + +Android 宿主通过 **HTTP + WebSocket + FCM** 与 walletman 服务端通信。服务端默认 `:16000`。 + +--- + +## 1. 架构 + +``` +Android 宿主 ◄── HTTP/WS ──► walletman :16000 ◄── 代理 TCP ──► 钱包 API + ◄── FCM data-only ──► Firebase +``` + +| 链路 | 用途 | +|------|------| +| HTTP REST | 登录、绑钱包、业务 | +| WebSocket `/ws` | 设备注册、TCP 代理、心跳 | +| FCM | WS 断线拉活、token 过期重绑 | + +**clientId:** 设备唯一 ID,WS 注册 key + FCM 目标 key,需与 App 侧 `DeviceInfo.getUniqueIdSync()` 一致。 + +--- + +## 2. 启动与配置 + +```bash +cd servers/walletman/cmd/server +go run . -tls android +``` + +测试页:`http://localhost:16000/test/index.html` + +**FCM 服务账号:** + +``` +servers/walletman/cmd/server/config/fcm-service-account.json +``` + +或 `FCM_SERVICE_ACCOUNT=/path/to/json`。须与宿主 App `google-services.json` **同一 Firebase 项目**。 + +--- + +## 3. 通用约定 + +**响应:** + +```json +{ "success": true, "message": "说明", "data": {} } +``` + +**需登录接口请求头:** + +``` +Content-Type: application/json +X-User-ID: 10000 +``` + +--- + +## 4. HTTP 接口 + +### POST `/login` + +```json +// req +{ "username": "test123", "password": "123456" } +// data +{ "userId": 10000, "userToken": "10000" } +``` + +### POST `/register` + +Token 绑钱包。 + +```json +{ + "walletType": "paytm", + "params": { + "type": "paytm", + "token": "...", + "refreshToken": "...", + "mobile": "9xxxxxxxxx", + "deviceId": "...", + "chType": "ipay", + "chVersion": "3" + } +} +``` + +```json +// data +{ "walletId": "paytm_9xxxxxxxxx", "walletType": "paytm" } +``` + +`walletId` = `{walletType}_{phone}`。 + +常用 `walletType`:`paytm` | `phonepe` | `mobikwik` | `freecharge` | `paytm business` | `phonepe business`。 + +### POST `/request-otp` / POST `/verify-otp` + +OTP 绑钱包,见 `http_handler.go`。 + +### GET `/wallets` + +```json +{ + "wallets": [{ + "id": "paytm_9876543210", + "walletType": "paytm", + "phone": "9876543210", + "upi": "xxx@paytm", + "status": "active", + "otpMode": false + }] +} +``` + +`inactive` + 非 OTP → 可能触发 FCM 重绑(`maybeNotifyWalletRebind`)。 + +### POST `/fcm/register` + +```json +{ "clientId": "设备ID", "fcmToken": "..." } +``` + +与 WS `register.data.fcmToken` 等价,均 `storeFcmToken`。 + +### POST `/fcm/send-wake` + +```json +{ "clientId": "xxx" } +// 或 { "userId": 10000 } +``` + +### POST `/fcm/send-rebind` + +```json +{ + "walletId": "paytm_9876543210", + "walletType": "paytm", + "phone": "9876543210", + "retry": false, + "notify": true +} +``` + +`clientId` 可省略,按 wallet owner 查 `GetFcmClientID`。 + +### GET `/fcm/clients` + +已注册 FCM 的 clientId 列表。 + +--- + +## 5. WebSocket `/ws` + +URL:`ws://host:16000/ws` 或 `wss://...` + +### 客户端 → 服务端 + +**register(连上即发)** + +```json +{ + "type": "register", + "messageId": "register_1700000000000", + "clientId": "设备ID", + "data": { "userId": 10000, "fcmToken": "可选" } +} +``` + +- 存 `clientId → Client` +- `userManager.SetProxy(userId, clientId, ...)` +- 同 clientId 新连接踢旧连接 + +**ping(建议 10s)** + +```json +{ "type": "ping", "messageId": "ping_xxx" } +``` + +**proxyReady / proxyData / proxyClose** + +| type | 说明 | +|------|------| +| proxyReady | TCP 已连 host:port | +| proxyData | data.data = base64 | +| proxyClose | 结束 | + +### 服务端 → 客户端 + +**proxyRequest** + +```json +{ + "type": "proxyRequest", + "messageId": "req_1700000000000", + "data": { "host": "api.phonepe.com", "port": 443 } +} +``` + +443 端口服务端侧做 TLS,再 HTTP/2 转发。 + +### 服务端行为 + +| 事件 | 行为 | +|------|------| +| WS 断开 | `cleanupClient` → `sendFcmWake(clientId)` | +| 45s 无活动 | 心跳超时清理 + wake | +| 代理时 client 离线 | `requestProxy` 失败 → wake | +| 钱包 API 请求 | `proxyRoundTripper` → `requestProxy` | + +代码:`websocket_handler.go`、`proxy.go`、`fcm.go`。 + +--- + +## 6. FCM 下发(data-only) + +重绑 **不发 notification 块**,避免绕过 App 静默逻辑。 + +| data key | 说明 | +|----------|------| +| msg | 文案(客户端 AIDL 失败才本地弹) | +| cmd | `wake_proxy` \| `rebind_wallet` | +| params | JSON 字符串 | + +### wake_proxy + +```json +{ "cmd": "wake_proxy", "params": "{}" } +``` + +### rebind_wallet + +```json +{ + "cmd": "rebind_wallet", + "msg": "Paytm 已过期,点我自动重绑", + "params": "{\"walletId\":\"paytm_xxx\",\"walletType\":\"paytm\",\"phone\":\"9xxx\",\"retry\":false,\"tryGtkAnyway\":false}" +} +``` + +PhonePe 默认 `retry=true`。 + +### 自动触发 + +`wallet.GetStatus() == inactive` 且 `!GetOTPMode()` → `sendFcmRebind(..., notify=true)`。 + +### 发送实现 + +`sendFcmToClient` → FCM HTTP v1 `projects/{id}/messages:send`。 + +--- + +## 7. 时序(服务端视角) + +**代理请求** + +``` +walletman HTTP 出网 + → GetProxyClientID(userId) + → requestProxy(clientId, host, 443) + → WS proxyRequest → 等 proxyReady + → 双向 proxyData +``` + +**断线拉活** + +``` +WS cleanupClient + → sendFcmWake(clientId) + → 客户端 wakeFromPrefs → 重连 WS register +``` + +**重绑** + +``` +inactive 检测 / 手动 /fcm/send-rebind + → FCM rebind_wallet + → 客户端静默 AIDL → POST /register(宿主实现) + → 失败则客户端弹通知,用户点击后走前端 handler +``` + +--- + +## 8. 调试 + +```bash +# 服务端日志 +go run . -tls android + +# 测试页 +http://localhost:16000/test/index.html +``` + +| 接口 | 用途 | +|------|------| +| GET /fcm/clients | 看已注册设备 | +| POST /fcm/send-wake | 手动拉活 | +| POST /fcm/send-rebind | 手动重绑 | + +| 现象 | 排查 | +|------|------| +| FCM send failed | 服务账号路径、project_id | +| no FCM token for clientId | App 未 POST /fcm/register 或 WS 未带 fcmToken | +| Client not connected | WS 未连;发 wake 后重试 | +| 重绑发了客户端无反应 | 查 FCM send ok 日志;Firebase 项目是否一致 | diff --git a/docs/walletman对接文档.md b/docs/walletman对接文档.md new file mode 100644 index 0000000..3e9cd74 --- /dev/null +++ b/docs/walletman对接文档.md @@ -0,0 +1,4 @@ +# WalletMan 对接文档索引 + +- [后端对接](./walletman后端对接.md) — HTTP / WS / FCM 下发、服务端配置与调试 +- [前端对接](./walletman前端对接.md) — rnwalletman 集成、Android 配置、JS API diff --git a/screens/HomeScreen.tsx b/screens/HomeScreen.tsx index a758ac0..f3fc10f 100644 --- a/screens/HomeScreen.tsx +++ b/screens/HomeScreen.tsx @@ -31,6 +31,8 @@ import { FreechargePersonalBind, proxyBackgroundService, type RebindConfig, + type PatchConfig, + type FcmNotificationTapPayload, PhonePePersonalBindResult, PaytmPersonalBindResult, MobikwikPersonalBind, @@ -57,6 +59,14 @@ import Api, { getServerDomain, } from '../services/api'; +/** ipay patch 期望,bind 与 ProxyService FCM 重绑共用 */ +const PATCH_EXPECT: PatchConfig = { + chType: 'ipay', + paytmChVersion: '3', + phonepeChVersion: '1', + mobikwikChVersion: '1', +}; + function formatWalletTypeLabel(walletType: string) { return walletType.replace(/\b\w/g, (c) => c.toUpperCase()); } @@ -77,6 +87,16 @@ function groupBoundWallets(wallets: WalletItem[]) { })); } +function getBindKeyForWalletType(walletType: string): string | null { + switch (walletType) { + case 'paytm': return 'paytm_personal_token'; + case 'phonepe': return 'phonepe_personal_token'; + case 'mobikwik': return 'mobikwik_personal_token'; + case 'freecharge': return 'freecharge_personal_token'; + default: return null; + } +} + function getBindKeyForWallet(item: WalletItem): string | null { const otp = item.otpMode === true; switch (item.walletType) { @@ -212,8 +232,10 @@ export default class HomeScreen extends Component { }; async componentDidMount() { + proxyBackgroundService.setNotificationTapHandler(this.handleNotificationTap); await loadServerDomain(); await this.loadAdid(); + await proxyBackgroundService.syncPatchConfig(PATCH_EXPECT); const doLogin = () => { Api.instance.login('test123', '123456').then(async () => { @@ -229,12 +251,32 @@ export default class HomeScreen extends Component { } componentWillUnmount() { + proxyBackgroundService.setNotificationTapHandler(null); this.stopProxyClient(); this.appStateSubscription?.remove(); } + handleNotificationTap = (payload: FcmNotificationTapPayload) => { + if (payload.cmd !== 'rebind_wallet') return; + const { walletId, walletType, phone } = payload.params; + const item = walletId + ? this.state.wallets.find(w => w.id === walletId) + : undefined; + if (item) { + this.handleRebind(item); + return; + } + if (walletType) { + const key = getBindKeyForWalletType(walletType); + if (key) this.openWalletBind(key, phone); + } + }; + handleAppStateChange = (nextAppState: AppStateStatus) => { - if (nextAppState === 'active') this.fetchWallets(); + if (nextAppState === 'active') { + this.fetchWallets(); + void proxyBackgroundService.syncPendingNotificationTap(); + } }; buildRebindConfig = (): RebindConfig => ({ @@ -249,6 +291,7 @@ export default class HomeScreen extends Component { this.clientId = DeviceInfo.getUniqueIdSync(); const userId = Api.instance.getUserId(); this.setState({ proxyStatus: 'connecting' }); + await proxyBackgroundService.syncPatchConfig(PATCH_EXPECT); await proxyBackgroundService.syncRebindConfig(this.buildRebindConfig()); await proxyBackgroundService.start({ wsUrl: Api.WS_URL, @@ -394,7 +437,7 @@ export default class HomeScreen extends Component { this.setState({ [key]: false, bindPrefillMobile: '' } as any); if (showPaytmPersonalBind && paytmPersonalBindType === 'tokenMode') { - const remoteVersion = '2'; + const { chType, paytmChVersion: remoteVersion } = PATCH_EXPECT; return ( { processString="Processing..." isDebug onSuccess={(result: PaytmPersonalBindResult) => { - if (result.chType != 'ipay') { + if (result.chType != chType) { Alert.alert('Bind Failed', 'Patched Paytm app not installed. Reinstall required'); return; } @@ -476,7 +519,7 @@ export default class HomeScreen extends Component { ); } if (showPhonePePersonalBind && phonePePersonalBindType === 'tokenMode') { - const remoteVersion = '1'; + const { chType, phonepeChVersion: remoteVersion } = PATCH_EXPECT; return ( { userToken={Api.instance.getUserToken()} isDebug onSuccess={(result: PhonePePersonalBindResult) => { - if (result.chType != 'ipay') { + if (result.chType != chType) { Alert.alert('Bind Failed', 'Patched PhonePe app not installed. Reinstall required'); return; }