开发一款个人收款

523 阅读7分钟

需要一部安卓机,开发一款个人收款。

Live-demo

网络上有许多的第三方提供了 app 或者 网站 让我们上传自己的收款码,它为我们提供 hook ,部分需要收费比如 pay.js ,当然肯定不止一个收费的。

免费的我也害怕,因为我不知道它怎么实现,所以我也有点担心会不会收费收费突然就被 cut 掉了。

所以最好的方法就是 自己做一套。 自己知根知底,就不会出错了。

这篇文章合适人选: 想要做一套个人收款的开发。

技术包括但不限于 Android、web、node

作为一名web开发人员,Android 是最难的问题。本来可以一天搞完的事情,硬生生被 Android 拖了 n 久。

个人就踩过坑,什么CA证书,http请求不安全,service,权限等等。都是让我懵逼的,现在踩过坑了。写一篇文章出来,让大家都可以做一个自己的收款app。

先说下思路,我这边的思路是只对一个 order表 进行操作。

用户在一个页面中,进行付款,会产生一个支付订单,时效只有5分钟,这个我放在redis里,如果超过5分钟,自动生成新的支付订单,并且删除老的订单,价格是我往下浮动2毛钱,就是说 5分钟 内 2元钱,只能同时有20个人并发,这个对个人用户来说应该足以,但是如果对于商用,是远远不够的。而且我这里还有一个字段 key ,根据这个key可以让同一个用户拉取同样价格支付订单,但是是不同的价格。以此区分用户付款后对应的是哪一个真正的订单。用户支付完成后,根据真实价格,反推出订单,即可。

订单表 主要有

field name describe
id 订单id 订单唯一标志
user 持单用户 标记用户信息
page 购买页面 目前我选择是页面,当然也可以是任何东西
isPayed 支付状态 判断是否支付
price 真实支付价格 记录真实价格

支付订单 用户

{
  leon: {
    20: [{price:19.8, key: 'page1', expired: 1574069463854}, {price:19.3, key: 'page2', expired: 1574069463854}] // leon同时在20元的区间里,拥有两个订单,如果订单过期或者支付成功,需要把价格重置到价格管理上
  }
}

价格管理

{
  20: [20, 19.9, 19.8, ..., 19.01]  // 20条数据,当前价格允许的浮动下,也可以进行30条,如果能接受的话。 :)
  30: [30, 29.9, 29.8, ..., 29.01]  // ....
}

主要用微信收款来说吧,因为当你看了下面的代码,你会发现支付宝也是一样的。

首先创建一个 android 的项目

WeChatd1a2570290447af93edfaa8a48ea3a23.png

找到上面的文件 AndroidManifest.xml

增加监控权限和网络请求权限 并且增加 service 的 NotificationListener ,这里主要是因为要重写这里的监听。

WeChat2ba278774b8d95f3fdb5fb0750df2b35.png

这里直接贴代码了。如果不想敲代码的话直接拷贝

AndroidManifest

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.z.monitor">

    <uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY"/>
    <uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"/>
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <service android:name=".NotificationListener"
            android:label="通知监听程序"
            android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
            <intent-filter>
                <action android:name="android.service.NotificationListenerService"/>
            </intent-filter>
        </service>
    </application>

</manifest>

修改 MainActivity 的代码,允许app获取监听的权限。

WeChat03d4111fb83a8d0b3fee0667f8ebe053.png

MainActivity

package com.z.monitor;

import androidx.appcompat.app.AppCompatActivity;

import android.content.Intent;
import android.os.Bundle;
import android.provider.Settings;

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        openNotificationListenSettings();
    }

    /**
     * 打开监听页面设置
     */
    public void openNotificationListenSettings() {
        try {
            Intent intent;
            if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP_MR1) {
                intent = new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS);
            } else {
                intent = new Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS");
            }
            startActivity(intent);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

NotificationListener 上的方法

WeChat2b164099650983fa0bb072071d2e6335.png

package com.z.monitor;

import android.annotation.TargetApi;
import android.app.Notification;
import android.app.PendingIntent;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.service.notification.NotificationListenerService;
import android.service.notification.StatusBarNotification;
import android.text.TextUtils;
import android.util.Log;
import android.widget.RemoteViews;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Field;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
public class NotificationListener extends NotificationListenerService {
    private static final String TAG = "NotificationListener";
    private static String PATH = "https://example.com"; // 回调的钩子页面
    private static URL url;

    static{
        try {
            url = new URL(PATH);
        } catch (MalformedURLException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void onNotificationPosted(StatusBarNotification sbn) {
        // 如果不是支付宝或者微信就直接忽略
        if (!"com.tencent.mm".equals(sbn.getPackageName()) &&
                !"com.eg.android.AlipayGphone".equals(sbn.getPackageName())) {
            return;
        }
        Notification notification = sbn.getNotification();
        if (notification == null) {
            return;
        }
        String realPrice = "";
        PendingIntent pendingIntent = null;
        // 当 API > 18 时,使用 extras 获取通知的详细信息
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            Bundle extras = notification.extras;
            if (extras != null) {
                // 获取通知标题
                String title = extras.getString(Notification.EXTRA_TITLE, "");
                Log.d(TAG,title);
                // 获取通知内容
                String content = extras.getString(Notification.EXTRA_TEXT, "");
                Log.d(TAG, content);
                if (!TextUtils.isEmpty(content)) {
                     if (content.contains("成功收款")) {
                         try {
                             String[] _tmp = content.split("元")[0].split("成功收款");
                             realPrice = _tmp[1];
                         } catch (Exception e) {
                            realPrice = "";
                         }
                     }
                      if (content.contains("微信支付收款")) {
                         try {
                             pendingIntent = notification.contentIntent;
                             String[] _tmp = content.split("微信支付收款")[1].split("元");
                             realPrice = _tmp[0];
                         } catch (Exception e) {
                             realPrice = "";
                         }
                      }
                }
            }
        } else {
            // 当 API = 18 时,利用反射获取内容字段
            List<String> textList = getText(notification);
            if (textList != null && textList.size() > 0) {
                for (String text : textList) {
                    if (!TextUtils.isEmpty(text)){
                        if (text.contains("成功收款")) {
                            try {
                                String[] _tmp = text.split("元")[0].split("成功收款");
                                realPrice = _tmp[1];
                            } catch (Exception e) {
                                realPrice = "";
                            }
                        }
                         if (text.contains("微信支付收款")) {
                           pendingIntent = notification.contentIntent;
                           try {
                                String[] _tmp = text.split("微信支付收款")[1].split("元");
                                realPrice = _tmp[0];
                            } catch (Exception e) {
                                realPrice = "";
                            }
                         }
                        break;
                    }
                }
            }
        }
        // 发送 pendingIntent 以此打开微信
        if (realPrice != "") {
            Map<String, String> params = new HashMap<String, String>();
            params.put("realPrice", realPrice);
            String result = sendPostMessage(params,"utf-8");
            System.out.println("result->"+result);
        }

        // 发送 pendingIntent 以此打开微信
        try {
            if (pendingIntent != null) {
                pendingIntent.send();
                moveBack();
            }
        } catch (PendingIntent.CanceledException e) {
            e.printStackTrace();
        }
    }

    public List<String> getText(Notification notification) {
        if (null == notification) {
            return null;
        }
        RemoteViews views = notification.bigContentView;
        if (views == null) {
            views = notification.contentView;
        }
        if (views == null) {
            return null;
        }
        // Use reflection to examine the m_actions member of the given RemoteViews object.
        // It's not pretty, but it works.
        List<String> text = new ArrayList<>();
        try {
            Field field = views.getClass().getDeclaredField("mActions");
            field.setAccessible(true);
            @SuppressWarnings("unchecked")
            ArrayList<Parcelable> actions = (ArrayList<Parcelable>) field.get(views);
            // Find the setText() and setTime() reflection actions
            for (Parcelable p : actions) {
                Parcel parcel = Parcel.obtain();
                p.writeToParcel(parcel, 0);
                parcel.setDataPosition(0);
                // The tag tells which type of action it is (2 is ReflectionAction, from the source)
                int tag = parcel.readInt();
                if (tag != 2) continue;
                // View ID
                parcel.readInt();
                String methodName = parcel.readString();
                if (null == methodName) {
                    continue;
                } else if (methodName.equals("setText")) {
                    // Parameter type (10 = Character Sequence)
                    parcel.readInt();
                    // Store the actual string
                    String t = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(parcel).toString().trim();
                    text.add(t);
                }
                parcel.recycle();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return text;
    }

    public static String sendPostMessage(Map<String, String> params, String encode){
        StringBuffer buffer = new StringBuffer();
        try {//把请求的主体写入正文!!
            if(params != null&&!params.isEmpty()){
                for(Map.Entry<String, String> entry : params.entrySet()){
                    buffer.append(entry.getKey()).append("=").
                            append(URLEncoder.encode(entry.getValue(),encode)).
                            append("&");
                }
            }
            // System.out.println(buffer.toString());
            //删除最后一个字符&,多了一个;主体设置完毕
            buffer.deleteCharAt(buffer.length()-1);
            byte[] mydata = buffer.toString().getBytes();
            HttpURLConnection connection = (HttpURLConnection) url.openConnection();
            connection.setConnectTimeout(3000);
            connection.setDoInput(true);//表示从服务器获取数据
            connection.setDoOutput(true);//表示向服务器写数据

            connection.setRequestMethod("POST");
            //是否使用缓存
            connection.setUseCaches(false);
            //表示设置请求体的类型是文本类型
            connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");

            connection.setRequestProperty("Content-Length", String.valueOf(mydata.length));
            connection.connect();   //连接,不写也可以。。??有待了解

            //获得输出流,向服务器输出数据
            OutputStream outputStream = connection.getOutputStream();
            outputStream.write(mydata,0,mydata.length);
            //获得服务器响应的结果和状态码
            int responseCode = connection.getResponseCode();
            if(responseCode == HttpURLConnection.HTTP_OK){
                return changeInputeStream(connection.getInputStream(),encode);

            }
        } catch (UnsupportedEncodingException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }

        return "";
    }

    /**
     * 将一个输入流转换成字符串
     * @param inputStream
     * @param encode
     * @return
     */
    private static String changeInputeStream(InputStream inputStream,String encode) {
        //通常叫做内存流,写在内存中的
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        byte[] data = new byte[1024];
        int len = 0;
        String result = "";
        if(inputStream != null){
            try {
                while((len = inputStream.read(data))!=-1){
                    data.toString();

                    outputStream.write(data, 0, len);
                }
                //result是在服务器端设置的doPost函数中的
                result = new String(outputStream.toByteArray(),encode);
                outputStream.flush();
            } catch (IOException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
        }
        return result;
    }

    /**
     * 触发 Home 键,回到主桌面
     * @return true
     */
    public boolean moveBack() {
        Intent backHome = new Intent(Intent.ACTION_MAIN);
        backHome.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        backHome.addCategory(Intent.CATEGORY_HOME);
        startActivity(backHome);
        return true;
    }
}

这个方法只有 minSdkVersion 在 18 以上才支持。看提示的,不过我把它弄成21了,一样好用。 在 src/build.gradle 上修改

WeChat0d10bbd90a15b5b61ca65924dec59b0c.png

build.gradle

apply plugin: 'com.android.application'

android {
    compileSdkVersion 29
    buildToolsVersion "29.0.2"
    defaultConfig {
        applicationId "com.z.monitor"
        minSdkVersion 21
        targetSdkVersion 29
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'androidx.appcompat:appcompat:1.1.0'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'androidx.test:runner:1.2.0'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}

这里好多代码都是我东拼西凑的,主要做的就是一件事情,如果有 微信/支付宝 的消息过来,我就对其截取,截取到价格之后,发送到支付成功的回调里。

这里尤其注意的一点就是,必须要用 https 。阿里云配置https还是很简单的 :) 反正我配好了,怎么说都可以。

插入手机,打包。

WeChat91645fadc9c516674c458e2b221b001c.png

对于web或者其他非Android开发人员来说,已经足够了。因为我们只要做到,能监听到钩子,其他的不过是业务逻辑上的问题而已。

其他的我就不一步步写了。主要是因为因为写的不严谨 :) ,改了几版。 我就直接传到 github 上去了。

用 3毛钱 做一个 测试demo , 有2毛是作为下浮的,大伙可以随便玩一下。

喜欢就随缘star一下项目 github.com/no-ane/pers…