Compare commits
17 Commits
b52bab194f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c42d89d585 | |||
| cc6a4443ee | |||
| 9477c1fed0 | |||
| a113805b51 | |||
| 2f4241951d | |||
| 57928cd97d | |||
| 0db8a9254a | |||
| d0f60b5ece | |||
| bb215fd492 | |||
| 01e597ac93 | |||
| 2a6fd5149f | |||
| 24d80ef505 | |||
| c1bf6690a2 | |||
| 061c690948 | |||
| d4127b1eba | |||
| 53f91447e4 | |||
| 5ed34ffc06 |
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -7,3 +7,6 @@
|
||||
[submodule "libs/rnauto"]
|
||||
path = libs/rnauto
|
||||
url = https://gitea.seaflygames.live/true_casey/rnauto.git
|
||||
[submodule "servers/usdtman"]
|
||||
path = servers/usdtman
|
||||
url = https://gitea.seaflygames.live/true_casey/usdtman.git
|
||||
|
||||
12
.vscode/launch.json
vendored
12
.vscode/launch.json
vendored
@@ -12,6 +12,18 @@
|
||||
"env": {
|
||||
"PATH": "/Users/hybro/go/bin:${env:PATH}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Debug USDTMan Server",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "debug",
|
||||
"program": "${workspaceFolder}/servers/usdtman/cmd/server",
|
||||
"cwd": "${workspaceFolder}/servers/usdtman",
|
||||
"preLaunchTask": "build usdtman",
|
||||
"env": {
|
||||
"PATH": "/Users/hybro/go/bin:${env:PATH}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
21
.vscode/tasks.json
vendored
21
.vscode/tasks.json
vendored
@@ -21,6 +21,27 @@
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "build usdtman",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": [
|
||||
"build",
|
||||
"-o",
|
||||
"${workspaceFolder}/servers/usdtman/bin/server",
|
||||
"./cmd/server"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/servers/usdtman"
|
||||
},
|
||||
"problemMatcher": [
|
||||
"$go"
|
||||
],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
1
android/.idea/vcs.xml
generated
1
android/.idea/vcs.xml
generated
@@ -7,6 +7,7 @@
|
||||
<mapping directory="$PROJECT_DIR$/../libs/rnwalletman" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/../node_modules/rnauto" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/../node_modules/rnwalletman" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/../servers/usdtman" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/../servers/walletman" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,6 +1,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
|
||||
206
components/OTPBindUI.tsx
Normal file
206
components/OTPBindUI.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React from 'react';
|
||||
import { View, TextInput, TouchableOpacity, Text, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
import { WalletType } from 'rnwalletman';
|
||||
import { useOTPBind } from '../hooks/useOTPBind';
|
||||
|
||||
interface OTPBindUIProps {
|
||||
walletType: WalletType;
|
||||
title: string;
|
||||
otpLength?: number;
|
||||
mobileLength?: number;
|
||||
onRequestOTP: (walletType: WalletType, params: any) => Promise<any>;
|
||||
onVerifyOTP: (walletType: WalletType, params: any) => Promise<any>;
|
||||
onSuccess: (result: any) => void;
|
||||
onError: (error: string) => void;
|
||||
isDebug: boolean;
|
||||
additionalParams?: any;
|
||||
}
|
||||
|
||||
export const OTPBindUI: React.FC<OTPBindUIProps> = ({
|
||||
walletType,
|
||||
title,
|
||||
otpLength = 6,
|
||||
mobileLength = 10,
|
||||
onRequestOTP,
|
||||
onVerifyOTP,
|
||||
onSuccess,
|
||||
onError,
|
||||
isDebug,
|
||||
additionalParams = {},
|
||||
}) => {
|
||||
const [state, actions] = useOTPBind(
|
||||
walletType,
|
||||
{
|
||||
onRequestOTP,
|
||||
onVerifyOTP,
|
||||
onSuccess,
|
||||
onError,
|
||||
isDebug,
|
||||
},
|
||||
{
|
||||
otpLength,
|
||||
mobileLength,
|
||||
additionalParams,
|
||||
}
|
||||
);
|
||||
|
||||
if (state.step === 'processing') {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator size="large" color="#fff" />
|
||||
<Text style={styles.processingText}>处理中...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.form}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
|
||||
{state.step === 'mobile' && (
|
||||
<>
|
||||
{state.errorMessage ? (
|
||||
<Text style={styles.errorText}>{state.errorMessage}</Text>
|
||||
) : null}
|
||||
<TextInput
|
||||
style={[styles.input, state.errorMessage ? styles.inputError : {}]}
|
||||
placeholder="请输入手机号"
|
||||
placeholderTextColor="#999"
|
||||
keyboardType="phone-pad"
|
||||
maxLength={mobileLength}
|
||||
value={state.mobile}
|
||||
onChangeText={(text) => {
|
||||
actions.setMobile(text);
|
||||
if (state.errorMessage) actions.clearError();
|
||||
}}
|
||||
editable={!state.loading}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, state.loading && styles.buttonDisabled]}
|
||||
onPress={actions.requestOTP}
|
||||
disabled={state.loading}
|
||||
>
|
||||
{state.loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>获取验证码</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.step === 'otp' && (
|
||||
<>
|
||||
<Text style={styles.hint}>验证码已发送至 {state.mobile}</Text>
|
||||
{state.errorMessage ? (
|
||||
<Text style={styles.errorText}>{state.errorMessage}</Text>
|
||||
) : null}
|
||||
<TextInput
|
||||
style={[styles.input, state.errorMessage ? styles.inputError : {}]}
|
||||
placeholder={`请输入 ${otpLength} 位验证码`}
|
||||
placeholderTextColor="#999"
|
||||
keyboardType="number-pad"
|
||||
maxLength={otpLength}
|
||||
value={state.otp}
|
||||
onChangeText={(text) => {
|
||||
actions.setOtp(text);
|
||||
if (state.errorMessage) actions.clearError();
|
||||
}}
|
||||
editable={!state.loading}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, state.loading && styles.buttonDisabled]}
|
||||
onPress={actions.verifyOTP}
|
||||
disabled={state.loading}
|
||||
>
|
||||
{state.loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>验证并绑定</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.linkButton}
|
||||
onPress={actions.resetToMobile}
|
||||
disabled={state.loading}
|
||||
>
|
||||
<Text style={styles.linkText}>重新输入手机号</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
form: {
|
||||
width: '80%',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderRadius: 5,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
marginBottom: 15,
|
||||
},
|
||||
inputError: {
|
||||
borderColor: '#ff3b30',
|
||||
},
|
||||
errorText: {
|
||||
color: '#ff3b30',
|
||||
fontSize: 14,
|
||||
marginBottom: 10,
|
||||
textAlign: 'center',
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#007AFF',
|
||||
borderRadius: 5,
|
||||
padding: 15,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#ccc',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
linkButton: {
|
||||
marginTop: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
linkText: {
|
||||
color: '#007AFF',
|
||||
fontSize: 14,
|
||||
},
|
||||
hint: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 10,
|
||||
textAlign: 'center',
|
||||
},
|
||||
processingText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
marginTop: 10,
|
||||
},
|
||||
});
|
||||
101
components/WalletBindComponents.tsx
Normal file
101
components/WalletBindComponents.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React, { Component } from 'react';
|
||||
import { WalletType, FreechargePersonalBindResult, MobikwikPersonalBindResult, PaytmPersonalBindResult, PhonePePersonalBindResult } from 'rnwalletman';
|
||||
import { OTPBindUI } from './OTPBindUI';
|
||||
|
||||
export class FreeChargeBind extends Component<{
|
||||
onRequestOTP: (walletType: WalletType, params: any) => Promise<any>;
|
||||
onVerifyOTP: (walletType: WalletType, params: any) => Promise<any>;
|
||||
onSuccess: (result: FreechargePersonalBindResult) => void;
|
||||
onError: (error: string) => void;
|
||||
isDebug: boolean;
|
||||
}> {
|
||||
render() {
|
||||
return (
|
||||
<OTPBindUI
|
||||
walletType={WalletType.FREECHARGE_PERSONAL}
|
||||
title="Freecharge 绑定"
|
||||
otpLength={4}
|
||||
onRequestOTP={this.props.onRequestOTP}
|
||||
onVerifyOTP={this.props.onVerifyOTP}
|
||||
onSuccess={this.props.onSuccess}
|
||||
onError={this.props.onError}
|
||||
isDebug={this.props.isDebug}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class MobikwikOTPBind extends Component<{
|
||||
onRequestOTP: (walletType: WalletType, params: any) => Promise<any>;
|
||||
onVerifyOTP: (walletType: WalletType, params: any) => Promise<any>;
|
||||
onSuccess: (result: MobikwikPersonalBindResult) => void;
|
||||
onError: (error: string) => void;
|
||||
isDebug: boolean;
|
||||
deviceId: string;
|
||||
tuneUserId: string;
|
||||
}> {
|
||||
render() {
|
||||
return (
|
||||
<OTPBindUI
|
||||
walletType={WalletType.MOBIKWIK_PERSONAL}
|
||||
title="Mobikwik 绑定"
|
||||
otpLength={6}
|
||||
onRequestOTP={this.props.onRequestOTP}
|
||||
onVerifyOTP={this.props.onVerifyOTP}
|
||||
onSuccess={this.props.onSuccess}
|
||||
onError={this.props.onError}
|
||||
isDebug={this.props.isDebug}
|
||||
additionalParams={{
|
||||
deviceId: this.props.deviceId,
|
||||
tuneUserId: this.props.tuneUserId,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class PayTmPersonalOTPBind extends Component<{
|
||||
onRequestOTP: (walletType: WalletType, params: any) => Promise<any>;
|
||||
onVerifyOTP: (walletType: WalletType, params: any) => Promise<any>;
|
||||
onSuccess: (result: PaytmPersonalBindResult) => void;
|
||||
onError: (error: string) => void;
|
||||
isDebug: boolean;
|
||||
}> {
|
||||
render() {
|
||||
return (
|
||||
<OTPBindUI
|
||||
walletType={WalletType.PAYTM_PERSONAL}
|
||||
title="Paytm 绑定"
|
||||
otpLength={6}
|
||||
onRequestOTP={this.props.onRequestOTP}
|
||||
onVerifyOTP={this.props.onVerifyOTP}
|
||||
onSuccess={this.props.onSuccess}
|
||||
onError={this.props.onError}
|
||||
isDebug={this.props.isDebug}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class PhonePePersonalOTPBind extends Component<{
|
||||
onRequestOTP: (walletType: WalletType, params: any) => Promise<any>;
|
||||
onVerifyOTP: (walletType: WalletType, params: any) => Promise<any>;
|
||||
onSuccess: (result: PhonePePersonalBindResult) => void;
|
||||
onError: (error: string) => void;
|
||||
isDebug: boolean;
|
||||
}> {
|
||||
render() {
|
||||
return (
|
||||
<OTPBindUI
|
||||
walletType={WalletType.PHONEPE_PERSONAL}
|
||||
title="PhonePe 绑定"
|
||||
otpLength={5}
|
||||
onRequestOTP={this.props.onRequestOTP}
|
||||
onVerifyOTP={this.props.onVerifyOTP}
|
||||
onSuccess={this.props.onSuccess}
|
||||
onError={this.props.onError}
|
||||
isDebug={this.props.isDebug}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
151
hooks/useOTPBind.ts
Normal file
151
hooks/useOTPBind.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useState } from 'react';
|
||||
import { WalletType } from 'rnwalletman';
|
||||
|
||||
export interface OTPBindCallbacks {
|
||||
onRequestOTP: (walletType: WalletType, params: any) => Promise<any>;
|
||||
onVerifyOTP: (walletType: WalletType, params: any) => Promise<any>;
|
||||
onSuccess: (result: any) => void;
|
||||
onError: (error: string) => void;
|
||||
isDebug?: boolean;
|
||||
}
|
||||
|
||||
export interface OTPBindState {
|
||||
mobile: string;
|
||||
otp: string;
|
||||
step: 'mobile' | 'otp' | 'processing';
|
||||
loading: boolean;
|
||||
otpData: any;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
export interface OTPBindActions {
|
||||
setMobile: (mobile: string) => void;
|
||||
setOtp: (otp: string) => void;
|
||||
requestOTP: () => Promise<void>;
|
||||
verifyOTP: () => Promise<void>;
|
||||
resetToMobile: () => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export function useOTPBind(
|
||||
walletType: WalletType,
|
||||
callbacks: OTPBindCallbacks,
|
||||
config?: {
|
||||
otpLength?: number;
|
||||
mobileLength?: number;
|
||||
additionalParams?: any;
|
||||
}
|
||||
): [OTPBindState, OTPBindActions] {
|
||||
const [mobile, setMobile] = useState('');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [step, setStep] = useState<'mobile' | 'otp' | 'processing'>('mobile');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [otpData, setOtpData] = useState<any>(null);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const { onRequestOTP, onVerifyOTP, onSuccess, onError, isDebug = false } = callbacks;
|
||||
const { otpLength = 6, mobileLength = 10, additionalParams = {} } = config || {};
|
||||
|
||||
const clearError = () => setErrorMessage('');
|
||||
|
||||
const log = (...args: any[]) => {
|
||||
if (isDebug) console.log(`[${walletType}]`, ...args);
|
||||
};
|
||||
|
||||
const error = (...args: any[]) => {
|
||||
if (isDebug) console.error(`[${walletType}]`, ...args);
|
||||
};
|
||||
|
||||
const requestOTP = async () => {
|
||||
if (!mobile || mobile.length !== mobileLength) {
|
||||
const msg = 'Invalid mobile number';
|
||||
setErrorMessage(msg);
|
||||
onError(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setErrorMessage('');
|
||||
log('Requesting OTP for:', mobile);
|
||||
|
||||
try {
|
||||
const response = await onRequestOTP(walletType, {
|
||||
mobile,
|
||||
...additionalParams,
|
||||
});
|
||||
log('OTP response:', response);
|
||||
|
||||
if (response.success) {
|
||||
setOtpData(response.data);
|
||||
setStep('otp');
|
||||
setErrorMessage('');
|
||||
} else {
|
||||
error('OTP request failed:', response.message);
|
||||
const msg = response.message || 'Failed to request OTP';
|
||||
setErrorMessage(msg);
|
||||
onError(msg);
|
||||
}
|
||||
} catch (e) {
|
||||
error('Request OTP error:', e);
|
||||
const msg = e instanceof Error ? e.message : 'Failed to request OTP';
|
||||
setErrorMessage(msg);
|
||||
onError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyOTP = async () => {
|
||||
if (!otp || otp.length !== otpLength) {
|
||||
const msg = `请输入 ${otpLength} 位验证码`;
|
||||
setErrorMessage(msg);
|
||||
onError(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setErrorMessage('');
|
||||
setStep('processing');
|
||||
log('Verifying OTP:', otp);
|
||||
|
||||
try {
|
||||
const response = await onVerifyOTP(walletType, {
|
||||
mobile,
|
||||
otp,
|
||||
...additionalParams,
|
||||
...(otpData || {}),
|
||||
});
|
||||
log('Verify response:', response);
|
||||
|
||||
if (response.success) {
|
||||
setErrorMessage('');
|
||||
onSuccess(response.data);
|
||||
} else {
|
||||
error('Verify failed:', response.message);
|
||||
const msg = response.message || 'Failed to verify OTP';
|
||||
setStep('otp');
|
||||
setErrorMessage(msg);
|
||||
onError(msg);
|
||||
}
|
||||
} catch (e) {
|
||||
error('Verify OTP error:', e);
|
||||
const msg = e instanceof Error ? e.message : 'Failed to verify OTP';
|
||||
setStep('otp');
|
||||
setErrorMessage(msg);
|
||||
onError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetToMobile = () => {
|
||||
setStep('mobile');
|
||||
setOtp('');
|
||||
setErrorMessage('');
|
||||
};
|
||||
|
||||
return [
|
||||
{ mobile, otp, step, loading, otpData, errorMessage },
|
||||
{ setMobile, setOtp, requestOTP, verifyOTP, resetToMobile, clearError },
|
||||
];
|
||||
}
|
||||
Submodule libs/rnwalletman updated: ad66622161...9e984f0edd
@@ -7,7 +7,8 @@
|
||||
"ios": "react-native run-ios",
|
||||
"lint": "eslint .",
|
||||
"start": "react-native start",
|
||||
"test": "jest"
|
||||
"test": "jest",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-cookies/cookies": "^6.2.1",
|
||||
@@ -15,6 +16,7 @@
|
||||
"buffer": "^6.0.3",
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.72.10",
|
||||
"react-native-background-actions": "^4.0.1",
|
||||
"react-native-device-info": "14.0.4",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-tcp-socket": "^6.4.1",
|
||||
@@ -35,6 +37,7 @@
|
||||
"eslint": "^8.19.0",
|
||||
"jest": "^29.2.1",
|
||||
"metro-react-native-babel-preset": "0.76.8",
|
||||
"patch-package": "^8.0.1",
|
||||
"prettier": "^2.4.1",
|
||||
"react-test-renderer": "18.2.0",
|
||||
"typescript": "4.8.4"
|
||||
|
||||
15
patches/react-native-background-actions+4.0.1.patch
Normal file
15
patches/react-native-background-actions+4.0.1.patch
Normal file
@@ -0,0 +1,15 @@
|
||||
diff --git a/node_modules/react-native-background-actions/android/src/main/java/com/asterinet/react/bgactions/RNBackgroundActionsTask.java b/node_modules/react-native-background-actions/android/src/main/java/com/asterinet/react/bgactions/RNBackgroundActionsTask.java
|
||||
index 9900fc0..d810b1c 100644
|
||||
--- a/node_modules/react-native-background-actions/android/src/main/java/com/asterinet/react/bgactions/RNBackgroundActionsTask.java
|
||||
+++ b/node_modules/react-native-background-actions/android/src/main/java/com/asterinet/react/bgactions/RNBackgroundActionsTask.java
|
||||
@@ -41,8 +41,8 @@ final public class RNBackgroundActionsTask extends HeadlessJsTaskService {
|
||||
notificationIntent = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER);
|
||||
}
|
||||
final PendingIntent contentIntent;
|
||||
- if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
- contentIntent = PendingIntent.getActivity(context,0, notificationIntent, PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT);
|
||||
+ if (Build.VERSION.SDK_INT >= 34) { // Android 14 (UPSIDE_DOWN_CAKE)
|
||||
+ contentIntent = PendingIntent.getActivity(context,0, notificationIntent, PendingIntent.FLAG_MUTABLE | 0x01000000); // FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
contentIntent = PendingIntent.getActivity(context, 0, notificationIntent, PendingIntent.FLAG_MUTABLE);
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
@@ -1,115 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Coinman 管理</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font: 14px/1.5 system-ui, sans-serif; max-width: 720px; margin: 0 auto; padding: 16px; }
|
||||
h1 { font-size: 1.25rem; margin: 0 0 1rem; }
|
||||
section { margin-bottom: 1.5rem; padding: 1rem; border: 1px solid #ddd; border-radius: 8px; }
|
||||
section h2 { font-size: 1rem; margin: 0 0 0.5rem; }
|
||||
input[type="text"], input[type="url"], textarea { width: 100%; padding: 8px; margin: 4px 0; border: 1px solid #ccc; border-radius: 4px; }
|
||||
textarea { min-height: 80px; resize: vertical; }
|
||||
button { padding: 8px 14px; margin: 4px 4px 4px 0; border: none; border-radius: 4px; background: #333; color: #fff; cursor: pointer; }
|
||||
button:hover { background: #555; }
|
||||
button.danger { background: #c00; }
|
||||
button.danger:hover { background: #e00; }
|
||||
.msg { margin-top: 6px; font-size: 12px; color: #666; }
|
||||
.err { color: #c00; }
|
||||
ul { list-style: none; padding: 0; margin: 0; }
|
||||
li { padding: 6px 0; border-bottom: 1px solid #eee; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
li:last-child { border-bottom: none; }
|
||||
.addr { word-break: break-all; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Coinman 管理后台</h1>
|
||||
|
||||
<section>
|
||||
<h2>Webhook 回调地址</h2>
|
||||
<input type="url" id="webhook" placeholder="https://your-server.com/webhook">
|
||||
<button onclick="saveWebhook()">保存</button>
|
||||
<div id="webhookMsg" class="msg"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>添加收款地址</h2>
|
||||
<textarea id="addAddrs" placeholder="每行一个 TRC20 地址"></textarea>
|
||||
<button onclick="addAddresses()">添加</button>
|
||||
<div id="addMsg" class="msg"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>当前监听地址</h2>
|
||||
<button onclick="loadAddresses()">刷新</button>
|
||||
<ul id="addrList"></ul>
|
||||
<div id="addrMsg" class="msg"></div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const base = location.origin;
|
||||
function escapeHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
function show(el, text, isErr) {
|
||||
const e = typeof el === 'string' ? document.getElementById(el) : el;
|
||||
if (!e) return;
|
||||
e.textContent = text;
|
||||
e.className = 'msg' + (isErr ? ' err' : '');
|
||||
}
|
||||
async function api(path, opts = {}) {
|
||||
const r = await fetch(base + path, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...opts
|
||||
});
|
||||
return r.json();
|
||||
}
|
||||
async function loadWebhook() {
|
||||
const j = await api('/config/callback');
|
||||
if (j.ok && j.data) document.getElementById('webhook').value = j.data.callback_url || '';
|
||||
}
|
||||
async function saveWebhook() {
|
||||
const url = document.getElementById('webhook').value.trim();
|
||||
const j = await api('/config/callback', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ callback_url: url })
|
||||
});
|
||||
show('webhookMsg', j.ok ? '已保存' : (j.msg || '失败'), !j.ok);
|
||||
}
|
||||
async function loadAddresses() {
|
||||
const j = await api('/addresses');
|
||||
const ul = document.getElementById('addrList');
|
||||
ul.innerHTML = '';
|
||||
if (!j.ok || !j.data.addresses || !j.data.addresses.length) {
|
||||
ul.innerHTML = '<li>暂无</li>';
|
||||
return;
|
||||
}
|
||||
j.data.addresses.forEach(addr => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = '<span class="addr">' + escapeHtml(addr) + '</span><button class="danger">删除</button>';
|
||||
li.querySelector('button').onclick = () => removeOne(addr);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
}
|
||||
async function addAddresses() {
|
||||
const raw = document.getElementById('addAddrs').value.trim();
|
||||
const addrs = raw.split(/\n/).map(s => s.trim()).filter(Boolean);
|
||||
if (!addrs.length) { show('addMsg', '请输入至少一个地址', true); return; }
|
||||
const j = await api('/addresses/add', { method: 'POST', body: JSON.stringify({ addresses: addrs }) });
|
||||
show('addMsg', j.ok ? '已添加 ' + addrs.length + ' 个' : (j.msg || '失败'), !j.ok);
|
||||
if (j.ok) { document.getElementById('addAddrs').value = ''; loadAddresses(); }
|
||||
}
|
||||
async function removeOne(addr) {
|
||||
const j = await api('/addresses/remove', { method: 'POST', body: JSON.stringify({ addresses: [addr] }) });
|
||||
show('addrMsg', j.ok ? '已删除' : (j.msg || '失败'), !j.ok);
|
||||
if (j.ok) loadAddresses();
|
||||
}
|
||||
loadWebhook();
|
||||
loadAddresses();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,201 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"rnpay/coinman"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const redisKeyCallback = "coinman:callback_url"
|
||||
|
||||
var (
|
||||
cm *coinman.Coinman
|
||||
rdb *redis.Client
|
||||
ctx = context.Background()
|
||||
)
|
||||
|
||||
//go:embed admin.html
|
||||
var adminFS embed.FS
|
||||
|
||||
func main() {
|
||||
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
|
||||
notifyStream := getEnv("NOTIFY_STREAM", "")
|
||||
port := getEnv("PORT", ":16001")
|
||||
|
||||
rdb = redis.NewClient(&redis.Options{Addr: redisAddr})
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
log.Fatalf("Redis: %v", err)
|
||||
}
|
||||
if u := getEnv("CALLBACK_URL", ""); u != "" {
|
||||
rdb.Set(ctx, redisKeyCallback, u, 0)
|
||||
}
|
||||
|
||||
cm = coinman.New(rdb)
|
||||
cm.OnOrderComplete(func(addr string, order coinman.OrderInfo) {
|
||||
url, _ := rdb.Get(ctx, redisKeyCallback).Result()
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
body, _ := json.Marshal(map[string]interface{}{"event": "usdt.received", "address": addr, "order": order})
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Printf("[coinman] callback err: %v", err)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
log.Printf("[coinman] callback -> %d", resp.StatusCode)
|
||||
})
|
||||
if notifyStream != "" {
|
||||
cm.SetNotifyStream(notifyStream)
|
||||
}
|
||||
cm.Start()
|
||||
|
||||
http.HandleFunc("/start", cors(handleStart))
|
||||
http.HandleFunc("/addresses", cors(handleAddresses))
|
||||
http.HandleFunc("/addresses/add", cors(handleAddressesAdd))
|
||||
http.HandleFunc("/addresses/remove", cors(handleAddressesRemove))
|
||||
http.HandleFunc("/config/callback", cors(handleConfigCallback))
|
||||
http.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
|
||||
data, _ := adminFS.ReadFile("admin.html")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(data)
|
||||
})
|
||||
|
||||
log.Printf("coinman listen %s (REDIS=%s, STREAM=%s)", port, redisAddr, boolStr(notifyStream != ""))
|
||||
log.Fatal(http.ListenAndServe(port, nil))
|
||||
}
|
||||
|
||||
func handleConfigCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
url, _ := rdb.Get(ctx, redisKeyCallback).Result()
|
||||
jsonResp(w, true, "ok", map[string]interface{}{"callback_url": url})
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResp(w, false, "Method not allowed", nil)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
CallbackURL string `json:"callback_url"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
url := strings.TrimSpace(req.CallbackURL)
|
||||
if url == "" {
|
||||
rdb.Del(ctx, redisKeyCallback)
|
||||
jsonResp(w, true, "ok", map[string]interface{}{"callback_url": ""})
|
||||
return
|
||||
}
|
||||
rdb.Set(ctx, redisKeyCallback, url, 0)
|
||||
jsonResp(w, true, "ok", map[string]interface{}{"callback_url": url})
|
||||
}
|
||||
|
||||
func getEnv(k, d string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func boolStr(b bool) string {
|
||||
if b {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
|
||||
func cors(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
if r.Method == http.MethodOptions {
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonResp(w http.ResponseWriter, ok bool, msg string, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": ok, "msg": msg, "data": data})
|
||||
}
|
||||
|
||||
func handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResp(w, false, "Method not allowed", nil)
|
||||
return
|
||||
}
|
||||
cm.Start()
|
||||
jsonResp(w, true, "started", nil)
|
||||
}
|
||||
|
||||
func handleAddresses(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResp(w, false, "Method not allowed", nil)
|
||||
return
|
||||
}
|
||||
list := cm.ListPaymentAddresses()
|
||||
jsonResp(w, true, "ok", map[string]interface{}{"addresses": list})
|
||||
}
|
||||
|
||||
func handleAddressesAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResp(w, false, "Method not allowed", nil)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Addresses []string `json:"addresses"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResp(w, false, "invalid json", nil)
|
||||
return
|
||||
}
|
||||
addrs := trimAddrs(req.Addresses)
|
||||
if len(addrs) == 0 {
|
||||
jsonResp(w, false, "addresses required", nil)
|
||||
return
|
||||
}
|
||||
cm.AddPaymentAddress(addrs)
|
||||
jsonResp(w, true, "ok", map[string]interface{}{"added": addrs})
|
||||
}
|
||||
|
||||
func handleAddressesRemove(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResp(w, false, "Method not allowed", nil)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Addresses []string `json:"addresses"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResp(w, false, "invalid json", nil)
|
||||
return
|
||||
}
|
||||
addrs := trimAddrs(req.Addresses)
|
||||
if len(addrs) == 0 {
|
||||
jsonResp(w, false, "addresses required", nil)
|
||||
return
|
||||
}
|
||||
cm.RemovePaymentAddress(addrs)
|
||||
jsonResp(w, true, "ok", map[string]interface{}{"removed": addrs})
|
||||
}
|
||||
|
||||
func trimAddrs(s []string) []string {
|
||||
out := make([]string, 0, len(s))
|
||||
for _, a := range s {
|
||||
a = strings.TrimSpace(a)
|
||||
if a != "" {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Binary file not shown.
@@ -1,413 +0,0 @@
|
||||
// Package coinman:USDT(TRC20) 多地址收款、轮询回调,支持分布式部署
|
||||
package coinman
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
tronGridBase = "https://api.trongrid.io"
|
||||
usdtContract = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
|
||||
redisKeyAddrs = "coinman:addresses"
|
||||
redisKeyLastTs = "coinman:last_ts:%s"
|
||||
redisKeyNotified = "coinman:notified:%s" // 分布式去重:仅第一个实例回调
|
||||
redisKeySeen = "coinman:seen:%s" // 已处理 tx_id 集合,避免重复
|
||||
pollDefault = 10 * time.Second
|
||||
)
|
||||
|
||||
// OrderInfo 单笔到账订单信息
|
||||
type OrderInfo struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Value string `json:"value"`
|
||||
TxID string `json:"tx_id"`
|
||||
BlockTs int64 `json:"block_ts"`
|
||||
}
|
||||
|
||||
// Coinman 收款管理(多地址、分布式)
|
||||
type Coinman struct {
|
||||
redis *redis.Client
|
||||
ctx context.Context
|
||||
onComplete func(address string, order OrderInfo)
|
||||
notifyStream string // 可选:到账时 XADD 到此 Stream,供多消费者
|
||||
pollInterval time.Duration
|
||||
mu sync.Mutex
|
||||
started bool
|
||||
stopCh chan struct{}
|
||||
memAddrs map[string]struct{} // redis 为 nil 时使用
|
||||
}
|
||||
|
||||
// New 创建 Coinman,redis 为 nil 时仅内存模式(单机)
|
||||
func New(redisClient *redis.Client) *Coinman {
|
||||
c := &Coinman{
|
||||
redis: redisClient,
|
||||
ctx: context.Background(),
|
||||
pollInterval: pollDefault,
|
||||
stopCh: make(chan struct{}),
|
||||
memAddrs: make(map[string]struct{}),
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Start 开启轮询与回调
|
||||
func (c *Coinman) Start() {
|
||||
c.mu.Lock()
|
||||
if c.started {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.started = true
|
||||
c.mu.Unlock()
|
||||
go c.pollLoop()
|
||||
}
|
||||
|
||||
// OnOrderComplete 设置到账回调:每笔新到账调用一次 (收款地址, 订单信息)
|
||||
func (c *Coinman) OnOrderComplete(fn func(address string, order OrderInfo)) {
|
||||
c.mu.Lock()
|
||||
c.onComplete = fn
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetNotifyStream 设置 Redis Stream:到账时 XADD,多消费者可独立消费,无需额外 MQ
|
||||
func (c *Coinman) SetNotifyStream(stream string) {
|
||||
c.mu.Lock()
|
||||
c.notifyStream = stream
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// AddPaymentAddress 动态添加收款地址(批量)
|
||||
func (c *Coinman) AddPaymentAddress(addrs []string) {
|
||||
if len(addrs) == 0 {
|
||||
return
|
||||
}
|
||||
if c.redis != nil {
|
||||
for _, a := range addrs {
|
||||
if a != "" {
|
||||
c.redis.SAdd(c.ctx, redisKeyAddrs, a)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
for _, a := range addrs {
|
||||
if a != "" {
|
||||
c.memAddrs[a] = struct{}{}
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// RemovePaymentAddress 动态删除收款地址(批量)
|
||||
func (c *Coinman) RemovePaymentAddress(addrs []string) {
|
||||
if len(addrs) == 0 {
|
||||
return
|
||||
}
|
||||
if c.redis != nil {
|
||||
c.redis.SRem(c.ctx, redisKeyAddrs, addrs)
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
for _, a := range addrs {
|
||||
delete(c.memAddrs, a)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// ListPaymentAddresses 返回当前监听的收款地址列表
|
||||
func (c *Coinman) ListPaymentAddresses() []string {
|
||||
return c.getAddresses()
|
||||
}
|
||||
|
||||
func (c *Coinman) getAddresses() []string {
|
||||
if c.redis != nil {
|
||||
list, _ := c.redis.SMembers(c.ctx, redisKeyAddrs).Result()
|
||||
return list
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := make([]string, 0, len(c.memAddrs))
|
||||
for a := range c.memAddrs {
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Coinman) getLastTs(addr string) int64 {
|
||||
if c.redis != nil {
|
||||
s, _ := c.redis.Get(c.ctx, fmt.Sprintf(redisKeyLastTs, addr)).Int64()
|
||||
return s
|
||||
}
|
||||
return 0 // 内存模式在 poll 里用本地 map 存 lastTs,见 pollLoop
|
||||
}
|
||||
|
||||
func (c *Coinman) setLastTs(addr string, ts int64) {
|
||||
if c.redis != nil {
|
||||
c.redis.Set(c.ctx, fmt.Sprintf(redisKeyLastTs, addr), ts, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// 分布式去重:仅一个实例能“认领”该 tx 并回调
|
||||
func (c *Coinman) claimNotify(txID string) bool {
|
||||
if c.redis != nil {
|
||||
key := fmt.Sprintf(redisKeyNotified, txID)
|
||||
ok, _ := c.redis.SetNX(c.ctx, key, "1", 7*24*time.Hour).Result()
|
||||
return ok
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Coinman) seen(addr, txID string) bool {
|
||||
if c.redis != nil {
|
||||
key := fmt.Sprintf(redisKeySeen, addr)
|
||||
n, _ := c.redis.SAdd(c.ctx, key, txID).Result()
|
||||
return n == 0 // 已存在
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Coinman) markSeen(addr, txID string) {
|
||||
if c.redis != nil {
|
||||
key := fmt.Sprintf(redisKeySeen, addr)
|
||||
c.redis.SAdd(c.ctx, key, txID)
|
||||
c.redis.Expire(c.ctx, key, 7*24*time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
// 内存模式下的 lastTs / seen 存在 Coinman 里
|
||||
type addrState struct {
|
||||
lastTs int64
|
||||
seen map[string]bool
|
||||
}
|
||||
var memState = struct {
|
||||
sync.RWMutex
|
||||
m map[string]*addrState
|
||||
}{m: make(map[string]*addrState)}
|
||||
|
||||
func (c *Coinman) getLastTsMem(addr string) int64 {
|
||||
memState.RLock()
|
||||
defer memState.RUnlock()
|
||||
if s, ok := memState.m[addr]; ok {
|
||||
return s.lastTs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (c *Coinman) setLastTsMem(addr string, ts int64) {
|
||||
memState.Lock()
|
||||
defer memState.Unlock()
|
||||
if memState.m[addr] == nil {
|
||||
memState.m[addr] = &addrState{seen: make(map[string]bool)}
|
||||
}
|
||||
memState.m[addr].lastTs = ts
|
||||
}
|
||||
|
||||
func (c *Coinman) seenMem(addr, txID string) bool {
|
||||
memState.Lock()
|
||||
defer memState.Unlock()
|
||||
if memState.m[addr] == nil {
|
||||
memState.m[addr] = &addrState{seen: make(map[string]bool)}
|
||||
}
|
||||
_, ok := memState.m[addr].seen[txID]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (c *Coinman) markSeenMem(addr, txID string) {
|
||||
memState.Lock()
|
||||
defer memState.Unlock()
|
||||
if memState.m[addr] == nil {
|
||||
memState.m[addr] = &addrState{seen: make(map[string]bool)}
|
||||
}
|
||||
memState.m[addr].seen[txID] = true
|
||||
}
|
||||
|
||||
func (c *Coinman) publishOrder(stream, addr string, order OrderInfo) {
|
||||
payload, _ := json.Marshal(map[string]interface{}{"address": addr, "order": order})
|
||||
c.redis.XAdd(c.ctx, &redis.XAddArgs{
|
||||
Stream: stream,
|
||||
Values: map[string]interface{}{"address": addr, "data": string(payload)},
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Coinman) pollLoop() {
|
||||
ticker := time.NewTicker(c.pollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-c.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.pollOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Coinman) pollOnce() {
|
||||
addrs := c.getAddresses()
|
||||
if len(addrs) == 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
onComplete := c.onComplete
|
||||
notifyStream := c.notifyStream
|
||||
c.mu.Unlock()
|
||||
if onComplete == nil && notifyStream == "" {
|
||||
return
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
var lastTs int64
|
||||
if c.redis != nil {
|
||||
lastTs = c.getLastTs(addr)
|
||||
} else {
|
||||
lastTs = c.getLastTsMem(addr)
|
||||
}
|
||||
transfers, err := fetchIncoming(addr, lastTs, 50)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var maxTs int64
|
||||
for _, t := range transfers {
|
||||
if !t.Confirmed {
|
||||
continue
|
||||
}
|
||||
seen := c.redis != nil && c.seen(addr, t.TxID)
|
||||
if !seen && c.redis == nil {
|
||||
seen = c.seenMem(addr, t.TxID)
|
||||
}
|
||||
if seen {
|
||||
if t.BlockTs > maxTs {
|
||||
maxTs = t.BlockTs
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c.redis != nil {
|
||||
c.markSeen(addr, t.TxID)
|
||||
} else {
|
||||
c.markSeenMem(addr, t.TxID)
|
||||
}
|
||||
if t.BlockTs > maxTs {
|
||||
maxTs = t.BlockTs
|
||||
}
|
||||
if !c.claimNotify(t.TxID) {
|
||||
continue
|
||||
}
|
||||
order := OrderInfo{From: t.From, To: t.To, Value: t.Value, TxID: t.TxID, BlockTs: t.BlockTs}
|
||||
if notifyStream != "" && c.redis != nil {
|
||||
c.publishOrder(notifyStream, addr, order)
|
||||
}
|
||||
if onComplete != nil {
|
||||
onComplete(addr, order)
|
||||
}
|
||||
}
|
||||
if maxTs > 0 {
|
||||
if c.redis != nil {
|
||||
c.setLastTs(addr, maxTs)
|
||||
} else {
|
||||
c.setLastTsMem(addr, maxTs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type transfer struct {
|
||||
From string
|
||||
To string
|
||||
Value string
|
||||
TxID string
|
||||
BlockTs int64
|
||||
Confirmed bool
|
||||
}
|
||||
|
||||
// FetchIncoming 拉取某地址 TRC20-USDT 转入记录(供 HTTP 查询接口)
|
||||
func FetchIncoming(address string, sinceTs int64, limit int) ([]OrderInfo, error) {
|
||||
list, err := fetchIncoming(address, sinceTs, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]OrderInfo, 0, len(list))
|
||||
for _, t := range list {
|
||||
if t.Confirmed {
|
||||
out = append(out, OrderInfo{From: t.From, To: t.To, Value: t.Value, TxID: t.TxID, BlockTs: t.BlockTs})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetTxStatus 查询单笔交易是否成功
|
||||
func GetTxStatus(txID string) (confirmed bool, blockTs int64, err error) {
|
||||
url := fmt.Sprintf("%s/wallet/gettransactionbyid?value=%s", tronGridBase, txID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var raw struct {
|
||||
Ret []struct{ ContractRet string } `json:"ret"`
|
||||
BlockTimestamp int64 `json:"block_timestamp"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
if len(raw.Ret) > 0 {
|
||||
confirmed = raw.Ret[0].ContractRet == "SUCCESS"
|
||||
}
|
||||
return confirmed, raw.BlockTimestamp, nil
|
||||
}
|
||||
|
||||
func fetchIncoming(address string, sinceTs int64, limit int) ([]transfer, error) {
|
||||
url := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20?only_to=true&contract_address=%s&limit=%d",
|
||||
tronGridBase, address, usdtContract, limit)
|
||||
if sinceTs > 0 {
|
||||
url += "&min_timestamp=" + fmt.Sprintf("%d", sinceTs)
|
||||
}
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var raw struct {
|
||||
Data []struct {
|
||||
TxID string `json:"transaction_id"`
|
||||
BlockTs int64 `json:"block_timestamp"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Value string `json:"value"`
|
||||
ContractRet string `json:"contract_ret"`
|
||||
} `json:"data"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &raw); err != nil || !raw.Success {
|
||||
return nil, fmt.Errorf("trongrid api err")
|
||||
}
|
||||
list := make([]transfer, 0, len(raw.Data))
|
||||
for _, d := range raw.Data {
|
||||
val := d.Value
|
||||
if len(val) > 6 {
|
||||
val = val[:len(val)-6] + "." + val[len(val)-6:]
|
||||
} else {
|
||||
val = "0." + val
|
||||
}
|
||||
list = append(list, transfer{
|
||||
From: d.From,
|
||||
To: d.To,
|
||||
Value: val,
|
||||
TxID: d.TxID,
|
||||
BlockTs: d.BlockTs,
|
||||
Confirmed: d.ContractRet == "SUCCESS",
|
||||
})
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
module rnpay/coinman
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/redis/go-redis/v9 v9.5.1
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
)
|
||||
@@ -1,10 +0,0 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8=
|
||||
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
||||
Binary file not shown.
1
servers/usdtman
Submodule
1
servers/usdtman
Submodule
Submodule servers/usdtman added at 753bd4a4d6
Submodule servers/walletman updated: 2bbe0eb877...d85e65d275
84
services/api.ts
Normal file
84
services/api.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { WalletType } from 'rnwalletman';
|
||||
|
||||
const DOMAIN = '192.168.0.101:16000';
|
||||
const BASE_URL = `http://${DOMAIN}`;
|
||||
const WS_URL = `ws://${DOMAIN}/ws`;
|
||||
|
||||
class Api {
|
||||
public static readonly BASE_URL = BASE_URL;
|
||||
public static readonly WS_URL = WS_URL;
|
||||
private static _instance: Api | null = null;
|
||||
private userId: number = 0;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public setUserId(userId: number) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public getUserId(): number {
|
||||
return this.userId;
|
||||
}
|
||||
|
||||
public static get instance() {
|
||||
if (Api._instance === null) {
|
||||
Api._instance = new Api();
|
||||
}
|
||||
return Api._instance;
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.userId > 0) {
|
||||
h['X-User-ID'] = String(this.userId);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
public async login(username: string, password: string): Promise<number> {
|
||||
const res = await fetch(`${Api.BASE_URL}/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.message);
|
||||
this.userId = data.data.userId;
|
||||
return this.userId;
|
||||
}
|
||||
|
||||
public async register(walletType: WalletType, params: any) {
|
||||
const res = await fetch(`${Api.BASE_URL}/register`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ walletType, params }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.message);
|
||||
return data;
|
||||
}
|
||||
|
||||
public async requestOTP(walletType: WalletType, mobile: string, params: any = {}) {
|
||||
const res = await fetch(`${Api.BASE_URL}/request-otp`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ walletType, mobile, ...params }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.message);
|
||||
return data;
|
||||
}
|
||||
|
||||
public async verifyOTP(walletType: WalletType, mobile: string, otp: string, params: any = {}) {
|
||||
const res = await fetch(`${Api.BASE_URL}/verify-otp`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ walletType, mobile, otp, params }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.message);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default Api;
|
||||
45
styles.ts
Normal file
45
styles.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
button: {
|
||||
padding: 10,
|
||||
backgroundColor: "lightblue",
|
||||
borderRadius: 5,
|
||||
width: 200,
|
||||
height: 55,
|
||||
},
|
||||
text: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
modal: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
modalContent: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
bindButton: {
|
||||
marginTop: 10,
|
||||
marginBottom: 10,
|
||||
backgroundColor: "#007AFF",
|
||||
borderRadius: 5,
|
||||
width: "90%",
|
||||
height: 45,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
bindButtonText: {
|
||||
fontSize: 14,
|
||||
// fontWeight: "bold",
|
||||
color: "#fff",
|
||||
},
|
||||
});
|
||||
25
types.ts
Normal file
25
types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface AppProps {}
|
||||
|
||||
export interface WalletmanAppState {
|
||||
/* Paytm Personal */
|
||||
showPaytmPersonalBind: boolean;
|
||||
paytmPersonalBindType: 'otpMode' | 'tokenMode';
|
||||
showPaytmBusinessBind: boolean;
|
||||
|
||||
/* PhonePe Personal */
|
||||
showPhonePePersonalBind: boolean;
|
||||
phonePePersonalBindType: 'otpMode' | 'tokenMode';
|
||||
showPhonePeBusinessBind: boolean;
|
||||
|
||||
/* GooglePay Business */
|
||||
showGooglePayBusinessBind: boolean;
|
||||
|
||||
/* BharatPe Business */
|
||||
showBharatPeBusinessBind: boolean;
|
||||
|
||||
/* Mobikwik Personal */
|
||||
showMobikwikPersonalBind: boolean;
|
||||
|
||||
/* Freecharge Personal */
|
||||
showFreechargePersonalBind: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user