wpa_supplicant - 进程间通信实现wifi连接

wpa_supplicant 是用于进行wifi连接认证和维护无线连接的一个应用,运行在安卓和各种嵌入式平台。wpa_supplicant在应用场景中所处的位置如图1所示:             

 

                                                                                 图1

  如图1所示,上层应用通过socket进程间通信向wpa_supplicant传送命令,从而实现wifi的连接。在产品运行过程中wpa_supplicant要一直保持运行,因为它还起到了维护链路的作用,比如说之前连接的wifi信号没了然后又有了,wpa_supplicant能及时的重新连接ap,并且通过进程间通信的接口能向上层应用汇报连接状态。

一.wpa_supplicant的启动

下面是wpa_supplicant的启动实例:

./wpa_supplicant -D wext -i wlan0 -c /etc/conf.d/wpa_supplicant.conf -B

其中主要说明两个地方:

-i wlan0 :这个是设置wifi连接的网络设备名称

-c /etc/conf.d/wpa_supplicant.conf :这个是设置wpa_supplicant配置文件的路径,该配置文件里决定了wpa_supplicant的进程间通信地址和保存之后连接的wifi信息。

下面是wpa_supplicant.conf文件内容实例:

ctrl_interface=/tmp/wpa_supplicant
update_config=1

ctrl_interface=/tmp/wpa_supplicant :这个是设置wpa_supplicant应用的进程间通信地址。

其他的配置可以看wpa_supplicant.conf的官方文档,其中网上常说的network配置项先不写,因为之后通过进程间通信配置好之后,wpa_supplicant应用会将配置的wifi信息写入配置文件当中。

二.wpa_supplicant 进程间通信

wpa_supplicant的进程间交互的方法有两种,一种是上层应用主动向wpa_supplicant发送请求然后接收响应,一种是wpa_supplicant随时向上层应用汇报各种状态。本文主要讲实现wifi连接,所以就只说主动请求的方法。

wpa_supplicant的源码中有一个应用wpa_cli,是一个指令工具,通过进程间通信将输入的指令发送到wpa_supplicant然后显示返回的信息。所以可以参考该应用的实现。

我们主要用src/common/wpa_ctrl.c文件中的几个部分代码:

1.进程间通信的开启:

struct wpa_ctrl * wpa_ctrl_open(const char *ctrl_path)
{
  struct wpa_ctrl *ctrl;
  static int counter = 0;
  int ret;
  size_t res;
  int tries = 0;

  ctrl = os_malloc(sizeof(*ctrl));
  if (ctrl == NULL)
    return NULL;
  os_memset(ctrl, 0, sizeof(*ctrl));

  ctrl->s = socket(PF_UNIX, SOCK_DGRAM, 0);
  if (ctrl->s < 0) {
    os_free(ctrl);
    return NULL;
  }

  ctrl->local.sun_family = AF_UNIX;
  counter++;
try_again:
  ret = os_snprintf(ctrl->local.sun_path, sizeof(ctrl->local.sun_path),
        CONFIG_CTRL_IFACE_CLIENT_DIR "/"
        CONFIG_CTRL_IFACE_CLIENT_PREFIX "%d-%d",
        (int) getpid(), counter);
  if (ret < 0 || (size_t) ret >= sizeof(ctrl->local.sun_path)) {
    close(ctrl->s);
    os_free(ctrl);
    return NULL;
  }
  tries++;
  if (bind(ctrl->s, (struct sockaddr *) &ctrl->local,
        sizeof(ctrl->local)) < 0) {
    if (errno == EADDRINUSE && tries < 2) {
      /*
       * getpid() returns unique identifier for this instance
       * of wpa_ctrl, so the existing socket file must have
       * been left by unclean termination of an earlier run.
       * Remove the file and try again.
       */
      unlink(ctrl->local.sun_path);
      goto try_again;
    }
    close(ctrl->s);
    os_free(ctrl);
    return NULL;
  }

#ifdef ANDROID
  chmod(ctrl->local.sun_path, S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP);
  chown(ctrl->local.sun_path, AID_SYSTEM, AID_WIFI);
  /*
   * If the ctrl_path isn't an absolute pathname, assume that
   * it's the name of a socket in the Android reserved namespace.
   * Otherwise, it's a normal UNIX domain socket appearing in the
   * filesystem.
   */
  if (ctrl_path != NULL && *ctrl_path != '/') {
    char buf[21];
    os_snprintf(buf, sizeof(buf), "wpa_%s", ctrl_path);
    if (socket_local_client_connect(
          ctrl->s, buf,
          ANDROID_SOCKET_NAMESPACE_RESERVED,
          SOCK_DGRAM) < 0) {
      close(ctrl->s);
      unlink(ctrl->local.sun_path);
      os_free(ctrl);
      return NULL;
    }
    return ctrl;
  }
#endif /* ANDROID */

  ctrl->dest.sun_family = AF_UNIX;
  res = os_strlcpy(ctrl->dest.sun_path, ctrl_path,
       sizeof(ctrl->dest.sun_path));
  if (res >= sizeof(ctrl->dest.sun_path)) {
    close(ctrl->s);
    os_free(ctrl);
    return NULL;
  }
  if (connect(ctrl->s, (struct sockaddr *) &ctrl->dest,
        sizeof(ctrl->dest)) < 0) {
    close(ctrl->s);
    unlink(ctrl->local.sun_path);
    os_free(ctrl);
    return NULL;
  }

  return ctrl;
}

2.进程间通信的关闭:

void wpa_ctrl_close(struct wpa_ctrl *ctrl)
{
  if (ctrl == NULL)
    return;
  unlink(ctrl->local.sun_path);
  if (ctrl->s >= 0)
    close(ctrl->s);
  os_free(ctrl);
}

3.发送指令请求:

int wpa_ctrl_request(struct wpa_ctrl *ctrl, const char *cmd, size_t cmd_len,
         char *reply, size_t *reply_len,
         void (*msg_cb)(char *msg, size_t len))
{
  struct timeval tv;
  int res;
  fd_set rfds;
  const char *_cmd;
  char *cmd_buf = NULL;
  size_t _cmd_len;

#ifdef CONFIG_CTRL_IFACE_UDP
  if (ctrl->cookie) {
    char *pos;
    _cmd_len = os_strlen(ctrl->cookie) + 1 + cmd_len;
    cmd_buf = os_malloc(_cmd_len);
    if (cmd_buf == NULL)
      return -1;
    _cmd = cmd_buf;
    pos = cmd_buf;
    os_strlcpy(pos, ctrl->cookie, _cmd_len);
    pos += os_strlen(ctrl->cookie);
    *pos++ = ' ';
    os_memcpy(pos, cmd, cmd_len);
  } else
#endif /* CONFIG_CTRL_IFACE_UDP */
  { 
    _cmd = cmd;
    _cmd_len = cmd_len;
  }

  if (send(ctrl->s, _cmd, _cmd_len, 0) < 0) {
    os_free(cmd_buf);
    return -1;
  }
  os_free(cmd_buf);

  for (;;) {
    tv.tv_sec = 10;
    tv.tv_usec = 0;
    FD_ZERO(&rfds);
    FD_SET(ctrl->s, &rfds);
    res = select(ctrl->s + 1, &rfds, NULL, NULL, &tv);
    if (res < 0)
      return res;
    if (FD_ISSET(ctrl->s, &rfds)) {
      res = recv(ctrl->s, reply, *reply_len, 0);
      if (res < 0)
        return res;
      if (res > 0 && reply[0] == '<') {
        /* This is an unsolicited message from
         * wpa_supplicant, not the reply to the
         * request. Use msg_cb to report this to the
         * caller. */
        if (msg_cb) {
          /* Make sure the message is nul
           * terminated. */
          if ((size_t) res == *reply_len)
            res = (*reply_len) - 1;
          reply[res] = '\0';
          msg_cb(reply, res);
        }
        continue;
      }
      *reply_len = res;
      break;
    } else {
      return -2;
    }
  }
  return 0;
}

源码中实现了多个平台多种连接方式的实现,本文主要讲述的是在linux环境下的wifi连接。三个接口具体调用顺序: 打开进程间通信 --> 发送请求  --> 关闭进程间通信(如果需要的话)。

三.封装wifi连接的接口:

wpaclient.h:

/*
#
# wifi连接接口
#   说明:通过进程间通信与wpa_supplicant进行交互实现wifi连接
#   注意:使用该接口的应用运行前要确保wpa_supplicant应用已经启动
# 并且能明确进程间通信地址。
#
*/
#ifndef __WPA_CLIENT_H__
#define __WPA_CLIENT_H__
#include <string>
#include <sys/un.h>
namespace wifi {
const std::string WPA_PATH = "/tmp/wpa_supplicant/wlan0";  //进程间通信地址加上网络接口额名称
struct WPAContext {
  int s;
  struct sockaddr_un local;
  struct sockaddr_un dest;
};

enum ConnectStatus {
  STEP_SCAN = 0,
  STEP_CONNECT_AP_OK,
  STEP_DHCP_IP,
  STEP_SUCCESS
};
class MXDHCP {        //dhcp ip地址的工具类,实现方法不算特别合理。可以根据具体情况进行更改
public:
  MXDHCP();
  ~MXDHCP();
  bool Start(const std::string& net_interface);
  bool GetDHCPStatus();
private:
  bool CheckString(char *buf,int len);
  FILE *pstream;
};

class WPAClient {
public:
  WPAClient(const std::string& wpa_control_path = WPA_PATH);
  ~WPAClient();
  bool GetInitStatus(){return wpa_context_!=nullptr;}   //获取wpa进程间通信是否建立连接
  int GetWiFiRssi();         //获取wifi信号强度,需要在连接成功之后调用
  std::string GetCurrentSSID();   //获取当前连接的wifi的名称
  bool ConnectWiFi(const std::string& ssid, const std::string& password);  //连接加密wifi,传入wifi名称和密码
  bool ConnectWiFiWithNoPassword(const std::string& ssid);   //连接无加密的wifi,传入wifi名称即可
  bool ConnectWiFiWithLast();  //直接连接上次已保存的wifi
  bool GetConnectStatus();    //获取wifi连接状态
protected:
  bool Init();
  struct WPAContext* Open(const std::string& path);
  void Close(struct WPAContext* context);
  bool Request(struct WPAContext* context, const std::string& cmd,std::string& reply);
  bool CheckCommandWithOk(const std::string cmd);
  bool AddWiFi(int& id);
  bool SetScanSSID(int id);
  bool SetSSID(const std::string& ssid, int id);
  bool SetPassword(const std::string& password, int id);
  bool SetProtocol(int id, int en_crypt);
  bool CleanAllWiFi();
  bool EnableWiFi(int id);
  void SetConnStatus(ConnectStatus status);
  int GetConnStatus();
protected:
  struct WPAContext* wpa_context_;
  std::string wpa_control_path_;
  MXDHCP dhcp_;
private:
  int step_;
};
}
#endif // __WPA_CLIENT_H__

wpaclient.cc:


#include "wpaclient.h"
#include <string>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdarg.h>
#include <errno.h>
#include <ctype.h>
#include <time.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

namespace wifi {

static std::string to_string(int val) {
  char *buf = NULL;
  int size;
  int temp;
  if (val < 0) {
    temp = -val;
    size = 2;
  }else{
    temp = val;
    size = 1;
  }
  for (; temp > 0; temp = temp / 10, size++);
  size++;
  buf = (char *)malloc(size);
  if (buf == NULL) {
    return "";
  }
  memset(buf, 0, size);
  sprintf(buf, "%d", val);
  std::string re(buf);
  free(buf);
  return re;
}


MXDHCP::MXDHCP() {
  pstream = NULL;
}

MXDHCP::~MXDHCP() {
  if(pstream!=NULL){
    pclose(pstream);
    pstream = NULL;
  }
}

bool MXDHCP::Start(const std::string & net_interface) {
  if(pstream!=NULL){
    pclose(pstream);
    pstream = NULL;
  }
  std::string cmd = "udhcpc -b -i " + net_interface + " &";
  system("killall -9 udhcpc &");
  usleep(1000*100);
  pstream = popen(cmd.data(),"r");
  if(pstream == NULL){
    return false;
  }
  return true;
}

/*
* dhcp 这个类的主要原理是通过读取udhcpc 的输出来判断是否拿到了ip地址,实际情况中可以使用别的方法
*/
bool MXDHCP::GetDHCPStatus() {
if(pstream == NULL){
    return false;
  }
  int len = 1024;
  char *buff = (char *)malloc(sizeof(char)*len);
  if(buff==NULL){
    return false;
  }
  int res = fread(buff,sizeof(char),len,pstream);
  if(res<=0){
    free(buff);
    return false;
  }
  if(!CheckString(buff,res)){
    free(buff);
    return false;
  }
  pclose(pstream);
  pstream = NULL;
  free(buff);
  return true;
}

bool MXDHCP::CheckString(char *buf ,int len)
{
  if(strstr(buf,"adding dns")==NULL){
    return false;
  }
  return true;
}


WPAClient::WPAClient(const std::string & wpa_control_path) {
  wpa_context_ = nullptr;
  wpa_control_path_ = wpa_control_path;
  SetConnStatus(STEP_SCAN);
  Init();
}

WPAClient::~WPAClient() {
  Close(wpa_context_);
}

bool WPAClient::Init() {
  wpa_context_ = Open(wpa_control_path_);
  if (wpa_context_ == nullptr) {
    return false;
  }
  SetConnStatus(STEP_SCAN);
  return true;
}

std::string WPAClient::GetCurrentSSID()
{
  std::string cmd = "STATUS";
  std::string ssid_key = "\nssid=";
  std::string recv;
  std::string ssid = "";
  if (!Request(wpa_context_, cmd, recv)) {
    return "";
  }
  char temp[1024] = {0};
  strcpy(temp, recv.data());
  char *key = NULL;
  key = strstr(temp, ssid_key.data());
  if (key == NULL) {
    return "";
  }
  key += ssid_key.length();
  for (; (*key != '\0') && (*key != '\n') && (*key != '\r'); key++) {
  ssid += *key;
  }
  return ssid;
}
int WPAClient::GetWiFiRssi() {
  std::string cmd = "STATUS";
  std::string rssi_key = "signal_level";
  std::string recv;
  if (!Request(wpa_context_, cmd, recv)) {
    return 0;
  }
  char temp[1024] = {0};
  strcpy(temp, recv.data());
  char *key = NULL;
  key = strstr(temp, rssi_key.data());
  if (key == NULL) {
    return 0;
  }
  for (; (*key != '\0') && (*key != '\n') && (*key != '\r'); key++) {
    if ((*key >= '0') && (*key <= '9')) {
      return atoi(key);
    }
  }
  return 0;
}

bool WPAClient::ConnectWiFi(const std::string & ssid, const std::string & password) {
  int net_id;
  SetConnStatus(STEP_SCAN);
  if (!CleanAllWiFi()) {
    return false;
  }
  if (!AddWiFi(net_id)) {
    return false;
  }
  if (!SetSSID(ssid, net_id)) {
    return false;
  }
  if (!SetPassword(password, 0)) {
    return false;
  }
  if (!SetProtocol(net_id, 1)) {
    return false;
  }
  SetScanSSID(net_id);
  if (!EnableWiFi(net_id)) {
    return false;
  }
  return CheckCommandWithOk("SAVE_CONFIG");
}

bool WPAClient::ConnectWiFiWithNoPassword(const std::string & ssid) {
  int net_id;
  SetConnStatus(STEP_SCAN);
  if (!CleanAllWiFi()) {
    return false;
  }
  if (!AddWiFi(net_id)) {
    return false;
  }
  if (!SetSSID(ssid, net_id)) {
    return false;
  }
  if (!SetProtocol(net_id, 0)) {
    return false;
  }
  SetScanSSID(net_id);
  if (!EnableWiFi(net_id)) {
    return false;
  }
  return CheckCommandWithOk("SAVE_CONFIG");
}

bool WPAClient::ConnectWiFiWithLast() {
  SetConnStatus(STEP_SCAN);
  if (!CheckCommandWithOk("ENABLE_NETWORK all")) {
    return false;
  }
  return true;
}

bool WPAClient::GetConnectStatus() {
  std::string cmd = "STATUS";
  std::string recv;
  int addr;
  switch (step_) {
  case STEP_SCAN:
    if (!Request(wpa_context_, cmd, recv)) {
      return false;
    }
    addr = recv.find("COMPLETED");
    if (addr == -1) {
      return false;
    }
    SetConnStatus(STEP_CONNECT_AP_OK);
  case STEP_CONNECT_AP_OK:
    if (!dhcp_.Start("wlan0")) {
      return false;
    }
    SetConnStatus(STEP_DHCP_IP);
  case STEP_DHCP_IP:
    if (!dhcp_.GetDHCPStatus()) {
      return false;
    }
    SetConnStatus(STEP_SUCCESS);
  case STEP_SUCCESS:
    return true;
  default:
    return false;
  }
  return false;
}

void WPAClient::SetConnStatus(ConnectStatus status) {
  step_ = status;
}

int WPAClient::GetConnStatus() {
  return step_;
}

WPAContext * WPAClient::Open(const std::string & path) {
  struct WPAContext *ctrl;
  ctrl = (struct WPAContext*)malloc(sizeof(struct WPAContext));
  if (ctrl == nullptr) {
    return nullptr;
  }
  memset(ctrl, 0, sizeof(struct WPAContext));
  static int counter = 0;
  int ret;
  int tries = 0;
  size_t res;
  ctrl->s = socket(PF_UNIX, SOCK_DGRAM, 0);
  if (ctrl->s < 0) {
    free(ctrl);
    return nullptr;
  }
  ctrl->local.sun_family = AF_UNIX;
  counter++;
try_again:
  ret = snprintf(ctrl->local.sun_path, sizeof(ctrl->local.sun_path),
    "/tmp" "/"
    "wpa_ctrl_" "%d-%d",
    (int)getpid(), counter);
  if (ret < 0 || (size_t)ret >= sizeof(ctrl->local.sun_path)) {
    close(ctrl->s);
    free(ctrl);
    return nullptr;
  }
  tries++;
  if (bind(ctrl->s, (struct sockaddr *) &ctrl->local,
    sizeof(ctrl->local)) < 0) {
    if (errno == EADDRINUSE && tries < 2) {
      /*
      * getpid() returns unique identifier for this instance
      * of wpa_ctrl, so the existing socket file must have
      * been left by unclean termination of an earlier run.
      * Remove the file and try again.
      */
      unlink(ctrl->local.sun_path);
      goto try_again;
    }
    close(ctrl->s);
    free(ctrl);
    return nullptr;
  }
  ctrl->dest.sun_family = AF_UNIX;
  res = strlcpy(ctrl->dest.sun_path, wpa_control_path_.data(),
    sizeof(ctrl->dest.sun_path));
  if (res >= sizeof(ctrl->dest.sun_path)) {
    close(ctrl->s);
    free(ctrl);
    return nullptr;
  }
  if (connect(ctrl->s, (struct sockaddr *) &ctrl->dest,
    sizeof(ctrl->dest)) < 0) {
    close(ctrl->s);
    unlink(ctrl->local.sun_path);
    free(ctrl);
    return nullptr;
  }
  return ctrl;
}

void WPAClient::Close(WPAContext * context) {
  if (context == nullptr)
    return;
  unlink(context->local.sun_path);
  if (context->s >= 0)
    close(context->s);
  free(context);
}

bool WPAClient::Request(WPAContext * context, const std::string & cmd, std::string& reply) {
  int res;
  fd_set rfds;
  struct timeval tv;
  const char *_cmd;
  char *cmd_buf = NULL;
  size_t _cmd_len;
  _cmd = cmd.data();
  _cmd_len = cmd.length();
  if (wpa_context_ == nullptr) {
    return false;
  }
  if (send(wpa_context_->s, _cmd, _cmd_len, 0) < 0) {
    free(cmd_buf);
    return -1;
  }
  free(cmd_buf);
  for (;;) {
    tv.tv_sec = 10;
    tv.tv_usec = 0;
    FD_ZERO(&rfds);
    FD_SET(wpa_context_->s, &rfds);
    res = select(wpa_context_->s + 1, &rfds, NULL, NULL, &tv);
    if (res < 0)
      return false;
    if (FD_ISSET(wpa_context_->s, &rfds)) {
      char temp[1024] = {0};
      int temp_len = 1024;
      res = recv(wpa_context_->s, temp, temp_len, 0);
      if (res < 0)
        return false;
      if (res > 0 && temp[0] == '<') {
        continue;
      }
      reply = temp;
      break;
    } else {
      return false;
    }
  }
  return true;
}
bool WPAClient::CheckCommandWithOk(const std::string cmd) {
  std::string recv;
  if (!Request(wpa_context_, cmd, recv)) {
    return false;
  }
  if (strstr(recv.data(), "OK") == NULL) {
    return false;
  }
  return true;
}
bool WPAClient::AddWiFi(int & id) {
  std::string add_cmd = "ADD_NETWORK";
  std::string recv;
  if (!Request(wpa_context_, add_cmd, recv)) {
    return false;
  }
  id = atoi(recv.data());
  return true;
}

bool WPAClient::SetScanSSID(int id) {
  std::string cmd = "SET_NETWORK " + to_string(id) + " scan_ssid 1";
  return CheckCommandWithOk(cmd);
}
bool WPAClient::SetSSID(const std::string & ssid, int id) {
  std::string cmd = "SET_NETWORK " + to_string(id) + " ssid " + "\"" + ssid + "\"";
  return CheckCommandWithOk(cmd);
}
bool WPAClient::SetPassword(const std::string & password, int id) {
  std::string cmd = "SET_NETWORK " + to_string(id) + " psk " + "\"" + password + "\"";
  return CheckCommandWithOk(cmd);
}
bool WPAClient::SetProtocol(int id, int en_crypt) {
  std::string cmd = "SET_NETWORK " + to_string(id);
  if (en_crypt) {
    cmd += " key_mgmt WPA-PSK";
    return CheckCommandWithOk(cmd);
  } else {
    cmd += " key_mgmt NONE";
    return CheckCommandWithOk(cmd);
  }
}
bool WPAClient::CleanAllWiFi() {
  CheckCommandWithOk("REMOVE_NETWORK all");
  CheckCommandWithOk("DISABLE_NETWORK all");
  return true;
}
bool WPAClient::EnableWiFi(int id) {
  std::string cmd = "ENABLE_NETWORK " + to_string(id);
  return CheckCommandWithOk(cmd);
}
}

demo.cc

#include <iostream>
#include <string>
#include <unistd.h>

#include "wpaclient.h"


int main() {
  std::string wifi_ssid = "test_ap";
  std::string wifi_passwd = "12345678";
  wifi::WPAClient wpa_client;
  if(!wpa_client.GetInitStatus()) {
    std::cout << "wpa client init failed!!!\n";
    return -1;
  }
  wpa_client.ConnectWiFi(wifi_ssid,wifi_passwd);
  int time_out = 15000;  //ms
  while(!wpa_client.GetConnectStatus()) {
    if(time_out <= 0){
      std::cout << "connect wifi: "<<wifi_ssid<<"time out\n";
      return -2;
    }
    time_out--;
    usleep(1000);
  }
  std::cout << "wifi connect success!!!\n";
  while(1) {
    std::cout << "ssid: "<<wpa_client.GetWiFiRssi()<<std::endl;
    sleep(3);
  }
  return 0;
}

三.封装说明

由于项目原因,wpaclient.cc的实现有几个特点需要各位读者注意:

1.只会保存一个wifi,每次连接新的wifi会清除之前保存的wifi。

2.只实现了两种wifi的连接,wpa2和开放wifi。其他加密协议(比如WPA-EAP)还需要读者自己添加

3.可以实现中文wifi的连接,应为连接wifi的部分并不关心字符编码,只要传入的ssid数据与ap实际编码一致都可以连接。

4.此模块是要等到设备dhcp成功后GetConnectStatus()接口才会返回true,为了能准确得到dhcp的成功或者失败,这里通过检测udhcpc指令输出的log实现,如果读者设备上的udhcpc不会输出wpaclient检测的信息log需要另外考虑。

5.此模块dhcp成功后会kill掉udhcpc,这样不好应对局域网ip过期的问题。如果需要考虑这个的读者可以联系我,这个我这里已经解决,只是要根据具体情况来定。

关于本文所述内容有什么意见和建议欢迎与我联系。

评论 16
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值