使用Ktor Server部署无人值守免签收款系统

Author Avatar
xjunz 9月 16, 2022
  • 在其它设备中阅读本文章

使用Ktor Server部署无人值守免签收款系统

本文可能出现错误和疏漏,欢迎批评斧正

需求

构建一个无人值守的免签支付宝收款系统,采用的技术栈为Ktor Server后端 + Postgres数据库 + Android客户端。所谓无人值守,即该系统不需要开发者手动操作便能完成整套的收款流程,所谓免签指的是不利用官方的收款平台或者任何第三方收款平台进行收款。

实现

我们的思路是,使用Ktor Server处理客户端请求和业务逻辑,使用Postgres数据库储存用户信息和订单信息,使用Android客户端接收收款并上报服务端。

其中有一个关键逻辑在于,如何使用客户端区分不同用户的收款而无需入侵支付宝本身。我们采用的方法为,使用收款金额区分。假如我们允许商品的价格随机折扣0至1元,而收款金额的粒度为“分”。也就是说,仅至多1元的折扣,就可以区分100个不同的订单。订单并不是瞬间失效的,我们需要给予用户充足的时间用于付款,假设订单的有效时长是5分钟,那就意味着,5分钟内允许100个不同的订单,即该系统最多能处理平均3秒一个订单,当然折扣额度和订单有效时间存在竞争关系,如何制定取决于你对订单有效时间和订单折扣之间的取舍。

还有一个需要解决的问题是,如何在客户端上即时获取支付宝的收款信息。这很简单,我们只需要开启收款提醒助手,这就是你在商店使用支付宝付款时经常会听到的“支付宝已到账XXX元”的语音提示所采用的服务。因为此服务涉及到广大商户的切实利益,它被设计得非常可靠和即时,而这正是我们所需要的。

优劣分析

  • 优势

不需要任何资质

不需要支付任何中间费用

接入简单

  • 劣势

需要收款客户端保持在线

要用折扣来区分订单

需要开发者自己的云服务器

接口容易受到攻击

服务端实现

使用Ktor Server

Ktor是Kotlin官方推出的网络请求库,同时支持后端和客户端乃至前端,其健壮性和可维护性都有官方背书。更为重要的是,对于Android开发者而言,我们不需要额外学习新的语言,就能以较低的准入门槛使用Ktor。官方网站:https://ktor.io/

首先,我们需要使用Intellij IDEA创建一个Ktor项目,如果您拥有Intellij IDEA Ultimate,可以使用其自带的脚手架工具,请参考https://ktor.io/docs/intellij-idea.html#create_ktor_project.

如果您和我一样是平民用户,用的是社区版,那么可以访问 https://start.ktor.io/#/settings 快速创建Ktor项目。Ktor包含众多的插件模块用于实现各式各样的功能,我们需要自行选择需要的插件。对于我们的项目,只需要勾选kotlinx.serializationContent NegotiationExposed, Postgres

image-1

点击 Generate project 按钮之后,在IDEA中打开刚刚下载的空项目,该项目的依赖项应该包括这些:

dependencies {
    implementation("io.ktor:ktor-server-content-negotiation-jvm:$ktorVersion")
    implementation("io.ktor:ktor-server-core-jvm:$ktorVersion")
    implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:$ktorVersion")
    implementation("io.ktor:ktor-server-auth-jvm:$ktorVersion")
    implementation("io.ktor:ktor-server-host-common-jvm:$ktorVersion")
    implementation("io.ktor:ktor-server-netty-jvm:$ktorVersion")

    implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
    implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
    implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion")

    implementation("org.postgresql:postgresql:$postgresqlVersion")

    implementation("ch.qos.logback:logback-classic:$logbackVersion")
}

在云服务器上搭建Postgres数据库

声明:我的云服务器搭载的是ubuntu20.04 64位系统,因此,以下所有命令都基于此系统。

  • 安装Postgres
sudo apt update
sudo apt install postgresql postgresql-contrib
  • 启动Postgres服务
systemctl start postgresql
  • 创建数据库
# 若在root用户下,则先切换到postgres用户下(postgressql自动创建的用户)
# postgres会自动创建一个名为postgres的角色(role)
# 角色是一个可以拥有数据库对象和权限的实体
sudo -u postgres
# 进入sql命令行
psql
# 默认情况下,postgres角色密码为空,请修改密码
/password;
# 创建数据库
CREATE DATABASE 数据库名称;
  • 或者创建新的角色(可选)
# 当然你也可以使用CREATE ROLE命令在postgres中创建新的角色
CREATE ROLE 角色名 WITH LOGIN PASSWORD ‘密码’
# 赋予该角色创建数据库的权限
ALTER ROLE 角色名 CREATEDB; 
# 切换到该角色
psql -U 角色名
# 在该角色下创建数据库
CREATE DATABASE 数据库名;

连接到Postgres数据库

回到我们的IDEA,我们尝试在项目中连接到刚刚创建的数据库,创建一个.kt文件,在其中定义:

fun Application.configureDatabases() {
    // 连接到数据库
    val config = environment.config
    val driverClassName = config.property("storage.driverClassName").getString()
    val jdbcURL = config.property("storage.jdbcURL").getString()
    val database = Database.connect(jdbcURL, driverClassName)
    // 创建缺失的表和列,Orders表我们在后面会定义
    transaction(database) {
       SchemaUtils.createMissingTablesAndColumns(Orders)
    }
}

这个方法会从项目配置中读取storage.driverClassNamestorage.jdbcURL,这两个属性是JDBC连接到Postgres数据库的参数。

我们可以在项目resources目录下的application.conf中找到这两个属性的定义:

storage {
  driverClassName = "org.postgresql.Driver"
  jdbcURL = "jdbc:postgresql://localhost:5432/数据库名?user=角色名&password=密码"
}

注意,要想成功读取该配置文件,我们需要在Applicatio.kt中将主方法定义为:

fun main(args: Array<String>): Unit = EngineMain.main(args)

这样写的目的是用 EngineMain 来启动一个嵌入式的 Netty 服务器,并从配置文件中加载应用程序环境和模块,而不用在代码中硬编码。

部署pgAdmin4

pgAdmin4是Postgres数据库的可视化管理工具,它可以被部署在云服务器上让我们远程访问数据库,而不用大费周章地远程连接到终端然后使用psql进行管理,官方网站:https://www.pgadmin.org/

# 安装 pgAdmin
sudo apt install pgadmin4
# 启动 webserver
sudo /usr/pgadmin4/bin/setup-web.sh

然后,你就可以使用http://[host]/pgadmin4/来远程管理数据库了。

需要注意的是,如果某个表不存在主键,那么pgAdmin4将无法用可视化的方式修改这个表。

使用Exposed框架进行数据库操作

Exposed框架是一个用于Kotlin语言的轻量级SQL库,它基于JDBC驱动,并且由JetBrains官方开发和维护。官方网站:https://github.com/JetBrains/Exposed

定义订单表

class Order(id: EntityID<Int>) : IntEntity(id) {

    companion object : IntEntityClass<Order>(Orders)

    var orderId: String by Orders.orderId

    var remoteAddress: String by Orders.remoteAddress

    var createTimestamp: Long by Orders.createTimestamp

    var expirationDuration: Int by Orders.expirationDuration

    var randomDiscount: Int by Orders.randomDiscount

    var priceAfterDiscount: Int by Orders.priceAfterDiscount

    var isPaid: Boolean by Orders.isPaid
        private set

    private var paidAt: String? by Orders.paidAt

    val isExpired: Boolean
        get() {
            return createTimestamp + expirationDuration < System.currentTimeMillis()
        }

    fun markAsPaid() {
        isPaid = true
        paidAt = formatCurrentTime()
    }
}

object Orders : IntIdTable() {
    val orderId = varchar("orderId", 128).uniqueIndex()
    val createTimestamp = long("createTimestamp")
    val expirationDuration = integer("expirationDuration")
    val randomDiscount = integer("randomDiscount")
    val priceAfterDiscount = integer("priceAfterDiscount")
    val isPaid = bool("isPaid").default(false)
    val paidAt = varchar("paidAt", 128).nullable()
}

其中Orders类代表订单在数据库中的Table,而Order类则是代表一个订单的实体类,它可以直接使用by关键字代理Orders中的列。我们的订单需要一个randomDiscount字段用于区分每个订单。

定义DTO

@Serializable
data class OrderDTO(
    val orderId: String,
    val createTimestamp: Long,
    val remainingMills: Int,
    val priceAfterDiscount: Int,
    val randomDiscount: Int,
    val alipayUrl: String
)

fun Order.toDTO(): OrderDTO {
    return OrderDTO(
        orderId = orderId,
        createTimestamp = createTimestamp,
        remainingMills = (createTimestamp + expirationDuration - System.currentTimeMillis()).toInt(),
        priceAfterDiscount = priceAfterDiscount,
        randomDiscount = randomDiscount,
        alipayUrl = Configurator.ALIPAY_URL
    )
}

OrderDTOOrder类的数据类,它保留了Order中一些我们希望返回给客户端的字段,并且使用@Serializable注解,这代表这个类可以被序列化为JSON。注意这是kotlinx.serialization插件所包含的注解,而不是JDK中的Serializable

定义DAO

我们需要定义一个DAO类对数据库进行增删改查,感谢Exposed为我们提供了非常方便的DAO方法,使得我们可以从繁琐易错的SQL中解脱:

object OrderDao {

    fun createNewOrder(deviceId: String, price: Price, discount: Int): Order {
        return Order.new {
            this.expirationDuration = Configurator.ORDER_EXPIRATION_DURATION
            this.createTimestamp =  System.currentTimeMillis()
            this.createTime = currentTimestamp.formatTime()
            this.randomDiscount = discount
            this.priceAfterDiscount = price.currentValue - discount
        }
    }
    
    fun finOrderByDiscount(discount: Int): Order? {
        return Order.find(Orders.discount eq discount).singleOrNull()
    }
}

处理请求

在Ktor Server中,我们使用routing方法来处理请求,定义一个方法Application.routeOrders:

val mutex = Mutex()
fun Application.routeOrders() {
 routing {
     post("/createOrder") {
        mutex.withLock {
           newSuspendedTransaction {
                 val discount = RandomDiscountDao.getRandomDiscount()
                 if (discount < 0) {
                     // Too many requests!
                     call.respond(HttpStatusCode.TooManyRequests)
                 } else {
                     val created = OrderDao.createNewOrder(
                         deviceId,
                         PriceDao.getActivePrice(),
                         discount
                     )
                     RandomDiscountDao.insertDiscount(discount, created)
                     call.respond(created.toDTO())
                 }
             }
         }
    }
}

这个方法会处理路径为http://[host]/createOrderPOST请求。我们使用newSuspendedTransaction方法来开启一段可挂起的事务(在协程中使用)。需要注意的是,面对可能的并发,为了处理创建订单时可能出现的竞态关系,我们使用Mutex进行加锁,保证并发情况下订单仍然能被安全地创建。如果随机折扣池已经填满,那么我们返回TooManyRequests告知客户端当前订单过多。

不是你是否注意到,我们直接返回了created.toDTO()。这便是Content Negotiation这个插件带给我们的能力,它会自动将OrderDTO转为JSON字符串返回给客户端,不过前提是,我们需要先安装这个插件:

fun Application.configureSerialization() {
    install(ContentNegotiation) {
        json()
    }
}

本地测试

显然,我们不能每次都将程序部署到服务器再进行测试,我们需要在本地先进行测试。那么,引入以下两个库:

testImplementation("io.ktor:ktor-server-tests-jvm:$ktorVersion")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion")

现在我们来测试一下并发情况下我们的所有订单是否被正常创建了:

首先在项目的test目录下创建测试入口类ApplicationTestimage-2

然后在其中定义

@Test
fun testCreateOrdersConcurrently() = testApplication {
    val orders = coroutineScope {
        (0..9).map {
            async {
                val response = client.post("/createOrder")
                assertEquals(HttpStatusCode.OK, response.status)
                Json.decodeFromString<OrderDTO>(response.bodyAsText())
            }
        }.awaitAll()
    }
    assertEquals(10, orders.size)
    val discounts = orders.map { it.randomDiscount }.distinct()
    assertEquals(10, discounts.size)
}

上述代码以客户端的身份并发地请求了10次创建订单,我们判断每次请求是否成功,并且判断返回的订单数量是否为10,最为重要的是,判断所有订单的折扣是否不同(别忘了,这是我们区分订单的依据!)

当然,直接运行上述测试代码是会报错的,因为我们本地并没有安装Postgres数据库。所以,请先在本地安装并启动Postgres数据库。本地开发环境以Windows为例:

先去下载官网的Windows端安装器,安装好后打开安装位置,比如我安装的位置是:D:\Program Files\PostgreSQL,在这里打开终端,初始化Postgres:

.\pg_ctl -D "D:\Program Files\PostgreSQL\14\data" init

完成后,启动Postgres服务

.\pg_ctl -D "D:\Program Files\PostgreSQL\14\data" start

为了方便使用pg_ctl命令,也可以将他的父目录加入环境变量的path里或者创建PGDATA环境变量,这样就不用手动输入-D参数了。但是悲催的是,由于我的安装路径里存在空格,导致就算指定了PGDATA,也没法正常使用。不出意外的话,服务就已经启动了,默认部署在5432端口。接下来创建数据库:

.\psql -U 角色名
CREATE DATABASE 数据库名;

这样,就可以在本地连接到Postgres数据库了。如果你在安装Postgres时勾选了pgAdmin4,那么便可以使用它来管理数据库。只要打开D:\Program Files\PostgreSQL\14\pgAdmin 4\bin\pgAdmin4.exe即可。

还有一种情况是,我们不希望以客户端的身份进行测试,而是直接测试服务端代码,那么,请使用application包裹代码块:

fun testCreateDiscountsConcurrently() = testApplication {
    application {
        GlobalScope.launch {
            val discounts = (0..9).map {
                async {
                    RandomDiscountDao.getRandomDiscount()
                }
            }.awaitAll()
            assertEquals(10, discounts.distinct().size)
        }
    }

最后,运行测试代码,然后调试代码直到所有测试通过。

部署程序到云服务器

打包

application.conf中,我们需要定义当前程序的模块入口、版本、部署端口和主机:

ktor {
  application {
    modules = [xxx.xxx.xxx.ApplicationKt.module]
    version = 1
  }
  deployment {
    port = 8081
    host = 0.0.0.0
  }
}

我们需要定义和模块入口相同名称的方法,这个方法会在程序初始化的时候被执行:

fun Application.module() {
    // 配置Content Negotiation
    configureSerialization()
    // 配置数据库
    configureDatabases()
    // 处理请求
    configureRouting()
}

接下来,我们需要将当前程序打为jar包,我使用的是shadowJar插件,在build.gradle.kts中添加:

plugins {
       // ...
    id("com.github.johnrengelman.shadow") version "7.1.2"
}

然后使用./gradlew shadowJar 进行打包,最终文件将会输出在/build/libs目录下。

手动部署

最后,就是将jar包推到服务器上然后运行即可。可以使用命令行,也可以使用可视化工具,比如在Windows上我推荐使用WinSCP :: Official Site :: Free SFTP and FTP client for Windows ,非常方便快捷。

我们将jar包推到/home/backends/service.jar目录下,接下来我们在该目录价创建一个restart.sh脚本文件用于启动它(请提前在云服务器上安装JRE):

# Find the process ID of the process containing "service.jar"
PID=$(ps aux | grep "service.jar" | grep -v grep | awk '{print $2}')

# Check if the process exists
if [ -z "$PID" ]; then
    echo "No process containing 'service.jar' found."
else
    # Kill the process
    kill -15 $PID
    echo "Process containing 'service.jar' has been killed."
fi

nohup java -jar service.jar > nohup.log 2>&1 &
echo "Service started..."

这段脚本会判断当前服务是否正在运行,如果是,停止该进程,然后重新启动该服务,程序产生的日志将会被输出到当前目录的nohup.log文件里。

注意我们使用 kill -15向Java进程发送信号让他正常退出,而我们在程序中通过Runtime.addShutdownHook来处理“善后事宜”:

 Runtime.getRuntime().addShutdownHook(object : Thread() {
        override fun run() {
            super.run()
            TransactionManager.closeAndUnregister(database)
        }
    })

这样可以防止Java进程被终止后,而postgres数据库的连接仍然未关闭导致数据库有限的连接池被占用。

自动部署

我使用的是阿里云的ECS服务器,阿里云在IDEA提供了一个插件叫做:Alibaba Cloud Toolkit,它可以帮助我们实现一键部署。

首先在顶栏上找到此插件的图标,点击deploy to ECSimage-3

然后如下进行配置:

image-4

我们将File指定为生成的jar包的路径,然后将Target Directory指定为云服务器上的路径,Command指定为我们之间创建的sh脚本文件的路径。通常情况下,每次shadowJar打包都会生成不同的jar文件名(根据程序的版本号),因此我们每次都要重新填写路径,相当麻烦,可以用以下方法解决,在build.gradlew.kts中添加:

import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar

tasks.withType<ShadowJar> {
    archiveFileName.set("service.jar")
}

这样,每次打包生成的文件都将保持不变。点击Run按钮即可一键部署!

安全组规则

虽然我们部署了服务,但是你会发现服务器仍然无法处理相应的请求,这是因为云服务器的安全组规则并未放行我们部署的端口,现在去供应商的控制台配置安全组规则,以阿里云为例:

image-5

登录后,在控制台主页左栏找到安全组选项,找到云服务器实例,点击进入,按如下方式或者你的需求配置(注意端口范围和授权对象):

image-6

配置完成后,如果不出意外,服务器就能正常响应请求了!

Android客户端

我们需要客户端处理收款并上报收款信息

监听系统通知

Android系统很贴心地为我们提供了这样一个服务叫做 NotificationListenerService,顾名思义,就是监听系统通知的服务类。

使用它,我们变可以实时地获取到支付宝的收款通知,并提取出其中的金额。

首先定义一个服务继承自

class NotificationMonitorService : NotificationListenerService() {
     companion object {

        val COMPONENT_NAME = ComponentName(app, NotificationMonitorService::class.java)

         // 判断我们是否拥有了监听系统通知的权限
        fun isPermissionGranted(): Boolean {
            val packageNames: Set<String> =
                NotificationManagerCompat.getEnabledListenerPackages(app)
            return packageNames.contains(BuildConfig.APPLICATION_ID)
        }

         // 强制重新启动服务
        fun toggleService() {
            val pm: PackageManager = app.packageManager
            pm.setComponentEnabledSetting(
                COMPONENT_NAME,
                PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP
            )
            pm.setComponentEnabledSetting(
                COMPONENT_NAME,
                PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP
            )
        }
    }
}

然后在清单文件中声明:

<service
    android:name=".NotificationMonitorService"
    android:exported="true"
    android:label="@string/app_name"
    android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
    <intent-filter>
        <action android:name="android.service.notification.NotificationListenerService" />
    </intent-filter>
</service>

这个服务当然不是任何应用都能随便启动的,它需要一个特殊的权限android.permission.BIND_NOTIFICATION_LISTENER_SERVICE,当APP启动时,我们直接请求用户授予此权限,因为用户也就我们自己了,这段逻辑不用太过复杂。

override fun onCreate(savedInstanceState: Bundle?) {
    if(!NotificationMonitorService.isPermissionGranted()){
        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
            if (NotificationMonitorService.isPermissionGranted()) {
                    toast(R.string.permission_granted)
                    NotificationMonitorService.toggleService()
                }
            } else {
                toast(R.string.permission_denied)
            }
            }.launch(
                Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).putExtra(
                Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME,
                NotificationMonitorService.COMPONENT_NAME
       		)
        )
    }
}

当用户授权后,我们的服务将会被系统自动拉起,在NotificationMonitorServiceonCreate方法中,我们将该服务提升为前台服务,这样可以在通知栏显示一个通知并保活:

override fun onCreate() {
    val channel = NotificationChannelCompat.Builder(
        CHANNEL_ID_FOREGROUND, NotificationManagerCompat.IMPORTANCE_DEFAULT
    ).setName(applicationInfo.labelRes.str).setShowBadge(false).build()
    notificationManager.createNotificationChannel(channel)
    startForeground(FOREGROUND_SERVICE_ID, buildForegroundNotification(State.IDLE))
 }

很有趣的是,其实Android系统会自动保活这个服务,并且会自动在开机时自动启动这个服务,可见这是Android系统开发人员是考虑到了类似于我们这样的需求而适配的特殊策略。

当通知服务成功连接时,系统会调用onListenerConnected方法,我们在这里获取唤醒锁保证设备不会在熄屏时休眠CPU:

override fun onListenerConnected() {
    super.onListenerConnected()
    wakeLock = powerManager.newWakeLock(
        PowerManager.PARTIAL_WAKE_LOCK, "NotificationMonitorService:Lock"
    )
    wakeLock.acquire()
}

相应地,在通知服务断开时,释放唤醒锁,如果是意外停止(通过我们自定义的willQuit标志来判断),则尝试重新绑定服务:

override fun onListenerDisconnected() {
    super.onListenerDisconnected()
    if (willQuit) {
        stopForeground(STOP_FOREGROUND_REMOVE)
        serviceState.removeObserver(stateObserver)
        releaseWakeLockIfNeeded()
    } else {
        requestRebind(ComponentName(app, NotificationMonitorService::class.java))
    }
}

处理通知

最重要的部分在于处理通知,当系统收到通知时,会回调onNotificationPosted方法:

override fun onNotificationPosted(sbn: StatusBarNotification?) {
     super.onNotificationPosted(sbn)
     sbn?.let { notification ->
         debugLogcat("Notification received: $sbn")
         if (notification.packageName == victimPackageName) {
             val extras: Bundle = notification.notification?.extras ?: return
             val title = extras.getString(Notification.EXTRA_TITLE, "")
             logcat(title)
             val content = extras.getString(Notification.EXTRA_TEXT, "")
             logcat(content)
             if (receivedNotificationIds.contains(notification.id)) {
                 logcat("Duplicated notification post event received, distinct!")
                 return
             }
             if (title != "支付宝收款") return
             val ret = content.firstGroupValue("支付宝到账(.+?)元")
             if (ret != null) {
                 receivedNotificationIds.add(notification.id)
                 callbacks.forEach {
                     it.onPaymentReceived(ret)
                 }
                 if (previousKey != null) {
                     cancelNotification(previousKey)
                 }
                 previousKey = notification.key
             } else {
                 logcat("Unmatched notification: title = $title")
             }
         }
     }
}

在这个方法中,我们使用正则表达式匹配并捕获收款的金额。需要注意的是,系统可能会对同一个通知多次进行回调,我们需要通过通知的id字段来判断此通知是否已经被处理过。在提取到金额之后,我们便可以将金额上报给服务器,服务器通过金额查询到订单,然后将该订单标记为已支付。同时,在用户侧,可以通过手动查询或者轮询来更新订单状态。

业务逻辑总结

以上便是一个简化版的免签收款系统:用户在产品客户端内点击创建订单——>向我们的服务端发送一个创建订单的请求——>服务端获取折扣金额并创建订单返回给客户端——>产品客户端根据订单的支付链接拉起支付宝进行付款——>用户付款——>收款客户端会收到来自支付宝的通知——>收款客户端提取收款金额并上报给服务端——>服务端将订单标记为已支付——>用户刷新订单状态——>完成支付。

总结

得益于Kotlin生态的茁壮发展,我们不用切换到其他语言即可完成服务端和客户端的开发,这是一件幸事。还有需要注意的是,以上代码只是这个系统的高度简化版,无论是服务端还是客户端,都还需要更多的业务逻辑以满足不同的需求。

有任何问题请在下方评论,谢谢!

知识共享许可协议
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

本文链接:http://www.xjunz.top/post/payment-backends-with-ktor-server/