此文档写于2017年3月,只能说明此时该文档适用。使用前请查看以下接口支付宝是否提供。
- 批量付款到支付宝账户
- (链接如有发生变化,请在官方文档中寻找此产品,一般情况下,产品名不会发生改变)
1. App支付产品
-
通俗上讲就是在App中使用支付宝付款,流程上就是:App请求接口(服务端),哪一个用户准备要买什么产品或者是要充多少钱,然后服务端拼接一些必要的参数返回给它,App端通过集成支付宝的SDK,根据接口返回的值去唤醒支付宝进行支付;与此同时,支付宝会异步通知服务端,哪一笔订单支付成功,服务端对充值后的逻辑做判断。(代码目前的做法)
-
见图
示例代码结构
使用说明:
- 操作者只需要填写Config所需的信息,根据自身业务抒写Controller、Service层即可,其他结构无需修改;在Service里调用ALiPayCore进行支付宝必要的步骤即可。
- ALiPayConfig配置参数概览:均为字符串
参数名 释义或固定值 取得方式或固定值 partner 合作身份者ID以2088开头由16位纯数字组成的字符串 参考前期业主申请操作 appid 应用id 同上 seller_email 商户支付宝账号 同上 account_name 商户真实姓名 同上 private_key 商户的私钥RSA 同上 ali_public_key 支付宝的公钥 RSA 同上 pay_sign_type 签名方式 现在支付宝支持2个值RSA或RSA2,该值须和私钥公钥生成方式一致。 notify_url 支付宝服务器主动通知商户服务器里指定的页面http/https路径 参考外网映射的配置方式https://www.ngrok.cc/ subject 商品的标题/交易标题/订单标题/订单关键字等 栗子:索尼-相机购买 以下值为固定值 --- --- method 接口名称 alipay.trade.app.pay version version 1.0 product_code 销售产品码,商家和支付宝签约的产品码 QUICK_MSECURITY_PAY input_charset 字符编码格式 目前支持 gbk 或 utf-8 utf-8 -
在Controller层抒写两个业务,处理APP调起支付和支付宝的回调;调起App的接口可以自定义参数,接收支付宝的回调参数无需修改。在Service里抒写自己的具体业务,然后调用固定方法进行处理。
-
eg:A用户要购买100元的衣服,提供给App的接口需要接收用户的id,衣服的id,在Service层里处理订单的业务之后,调用ALiPayCore.createAliPayStr (out_trade_no,total_fee)方法,传入商户内部订单号和用户所要支付的金额,并将返回值返给前端即可;
前端接收到返回值调起支付宝SDK,用户支付完毕;
支付宝会异步通知在Config里配置的回调地址,在回调的Service层里,调用ALiPayCore.checkAliPayParam(request)进行验证,判断是否是支付宝发送来的通知以及是否支付成功,返回值不为NULL即可进行商品发货或者填写快递订单号等业务。 -
两个核心方法使用介绍:
1.String orderString = ALiPayCore.createAliPayStr (String out_trade_no,String total_fee);
a) 拼接支付宝支付必要参数,加签,并返回字符串;
b) out_trade_no 传入商户内部订单号,必须唯一;total_fee 所要支付的金额,格式必须为0.00,单位为元;
c) 返回App端调起支付所需要的参数。2.Map<String, String> aliParam = ALiPayCore.checkAliPayParam (request);
a) 支付成功的回调:检测支付宝异步反馈是否真实,不为null为真实,null为校验失败。
b) 返回的map中可以取得支付宝返回的参数,一般只取out_trade_no(商户内部订单号)、total_amount(充值的金额,单位为元)、trade_no(支付宝订单号)。
c) 判断aliParam是否不为NULL,不为NULL拿商户内部订单号去查出充值的订单数据,完成自己的业务。- 特别注意:在处理的回调里,处理成功后需要向支付宝反馈成功:
Eg:out.print(“success”); *
如果处理失败则无需反馈;支付宝如果接收不到正确反馈,则会按照一定策略重复通知,代码中应该有去重处理[判断该订单是否已经处理过*]
注意事项:
ALiPayCore类里的方法均不需要修改,和业务无关,直接调用即可。
引用jar:
集成支付宝接口需要引入的文件是:
- alipay-sdk-java*.jar
- commons-logging-1.1.1.jar
若进一步了解代码实现请引入文件:
- alipay-sdk-java*-source.jar
- commons-logging-1.1.1-sources.jar
4.备注以及拓展:
文档的第一步骤可以截取发给业主参考申请,让业主提供加粗的参数信息;
- 本着程序员应全身心在开发上,避免不必要的时间浪费。
对ALiPayCore.createAliPayStr()方法的解释:
- 方法的作用就是按照支付宝的要求返回字符串,APP端根据此返回值调起支付宝;
- 字符串里包含着订单信息,[支付宝要扣多少钱、支付宝服务端给哪个地址发送支付成功的通知];
- 不论是支付宝还是微信都有自己的一套规则,但大体上都很相似:
1.将参数的key值按照ascii码的顺序进行排序,并转化成key=value&key=value的形式;
2.然后支付宝会让调用各自语言的SHA加密算法,利用自己的私钥对刚刚的key=value进行加密得到sign参数,再将sign当作key,拼入字符串中;
3.为了防止中文value乱码,需要对所有的value进行URLEncoder.encode。 - 支付宝以上的3步是为了确保这个通知是商户的自身发送出来的,拿私钥加签实际上是采用了非对称性加密的方式,确保唯一性。[如果支付宝不这么做,订单信息被别人篡改了,对双方都将是最坏的结果]。
对ALiPayCore.checkAliPayParam()方法的解释:
- 方法的作用就是检测是否是支付宝发送来的通知,根据此通知进行支付成功的业务;
- 支付宝是不会发送支付失败的通知的;
- 验证也有相应的规则:
1.将支付宝的参数都取出[这里有个拓展,requesr.getgetParameterMap()就可以取出Map形式的参数];
2.剔除sign和sign_type字段,变成按照ascii码排序的key=value&形式;
3.调用各自语言对应的验签方法,这一步拿支付宝公钥进行验证返回布尔值;
4.以上的步骤其实也是非对称性的验证方式。 - 同理,支付宝需要这样的验证步骤,也是为了确保该通知是支付宝发出的,以防别人冒发送请求;
- 支付宝防止意外,还让对订单号、商户的基本信息做验证。
拓展:
- 关于数字签名这里有一篇文章介绍的很全面。
- 也推荐关注该文章的翻译者:阮一峰。
新发现:
重新修改此文时,发现支付宝更新了SDK的介绍页面,在这个链接里,支付宝也介绍了如何通过调用SDK完成服务端的工作。
4. 批量付款到支付宝账户详解
1. 前期业主申请操作:
-
找文档
这个藏得很深的,因为在支付宝的明面上看不到它,不像App支付,在类似产品大全的页面能找到;首先进入支付宝的 文档中心 在左侧找到 历史接口 ,但愿你看到的还是这个样子,如果在那一栏底下也标注了新的接口,那么你就重写它吧(笑)。
历史接口
-
业主签约
由于属于历史接口,支付宝的考量可能是不建议新商家接入,所以签约的方式比较繁杂,需要App支付产品签约成功后,再找人工客服签约。
图1
人工客服可以通过登录支付宝开放平台,如图1所示的地方唤醒;唤醒的是一只机器人,和它聊三句以上的话,会提醒接入人工,如图2。然后就可以和人工说我需要接入批量付款到支付宝账户这个有密接口,跟着她的节奏来进行了。这一步也是需要业主完成的,因为有业主需要考量的东西,程序员做不了主。
图2
人工客服大概会说,该接口每笔会收取0.5%的手续费,不满1元按1元算,最高不会超过25元,您同意吗?,同意就签约了。一般这个费用是转嫁给用户的,看业主如何考量了。2.程序员进行配置、抒写:
-
获取PID、MD5Key
pid就不说了,以2088开头的数字;
配置密钥, 参照文档 上指引的地方,把MD5那个key保存下来,用于请求数据的签名和支付宝返回数据的验签(后期才注意到,在“提现”这一模块,签名是用MD5签名的)。这里就不放图了,支付宝的网页跳来跳去,而且经常改动,无法确切它的位置,文档里倒是更新开很快,这点比某信要强多了。-
简单综述一下:
用户,在App中填写好自己的支付宝真实姓名以及对应的支付宝账户,然后在提现那个地方,输上提现的金额,点击提交,接口就接收了这个数据,做一定的处理(比如转嫁手续费什么的),在提现表里有一个状态标识是提现申请、处理中、成功、失败;后台处理申请的提现,拼接一些参数给支付宝,处理成功后,支付宝给回馈,成功的成功,失败的失败。成功,失败拿系统消息作为提示,失败之后返回给用户金钱,可以重新提交申请。失败的原因可能是用户支付宝填的不正确,正常情况下是这样的。但是有很多意外情况,也就带来了很多坑,下面会拿粗体涉及。
-
App接口处理方面
对简单的参数做验证后,查看用户是否绑定了支付宝账户信息,以及用户的提现的钱是否符合够用(项目里是最低标准是5元)。
/** * 提现申请 * @return */ @ResponseBody @RequestMapping("applicationWithdraw") public Object applicationWithdraw(Integer pid, BigDecimal money){ //验证参数 if(ObjectUtil.isPassInteger(pid) && money != null && money.compareTo(new BigDecimal(5)) != -1){ if(aliacount_dao.selectByPersionId(pid).size()!=0){//用户已绑定支付宝 Persion persion = persion_dao.queryPersionByIdForIM(pid); if(money.compareTo(new BigDecimal(persion.getCapitalBalance())) != 1){ // 申请提现业务 boolean flag = wiService.insertApplication(persion, money); return flag==true?"success":"error"; }else{ return "error";//用户金额不足 } }else{//用户未绑定支付宝 return "fail"; } }else{ return "error"; } }
下面这块给拼接了一个流水号,在后面会有介绍;然后转嫁手续费到用户身上,插记录,扣钱;以事务的方式进行控制。关于用户手续费方面,其实还可以更加灵活一些,无非就是多提少收,少提多收;这个也得业主考量了。
** * 申请提现业务 * @return */ @Transactional public boolean insertApplication(Persion persion,BigDecimal money){ String onceId = UUIDHashCode.getOrderIdByUUId();//获得一个随机唯一标识 //扣除支付宝的手续费 0.5% 最高不会超过25元 最低不会超过1元 BigDecimal platform_cost = money.multiply(new BigDecimal(0.005)).setScale(2, BigDecimal.ROUND_HALF_UP); if(platform_cost.compareTo(new BigDecimal(25)) == 1){ platform_cost = new BigDecimal(25); }else if(platform_cost.compareTo(new BigDecimal(1)) == -1){ platform_cost = new BigDecimal(1); } BigDecimal getMoney = money.subtract(platform_cost);//扣除手续费之后的金额 int num = 0 ; if(getMoney.compareTo(new BigDecimal(0)) == 1){ // 新增一条提现申请记录 num = withMa.insertSelective(new Withdraw(onceId,persion.getId(),getMoney,1,platform_cost)); } if(num == 1){ // 修改该用户信息中可提现金额信息 persion.setCapitalBalance(money.doubleValue()); Persion queryPersion = perMa.queryPersionByIdForIM(persion.getId()); if(queryPersion != null){ persion.setVersion(queryPersion.getVersion()); int withdr = perMa.cutPersionCap(persion); return withdr>0?true:false; }else{ num = 1/0; return false; } }else{ num = 1/0; return false; } }
-
后台进行处理
因为这个提现还是需要人工操作的,比如输支付密码什么的,支付宝的考量也可能是为了安全吧,所以需要有后台的一系列逻辑。像App支付一样,先从基本信息配置入手。
AlipayConfig 这里关键在于私钥和公钥,可以看一看 阮一峰的数字签名是什么 支付宝App支付的公钥和这里提现所用的公钥是不一致的,对应的RSA的与RSA2的公钥也是不同的。之前不了解公钥的意义,这里的公钥和App支付的共用了,也算是无知的坑吧。商户安全校验码就是上面让保存的那个MD5的key值。public class AlipayConfig { //↓↓↓↓↓↓↓↓↓↓请在这里配置您的基本信息↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ // 合作身份者ID,以2088开头由16位纯数字组成的字符串 public static String partner = "2088xxxxxxx"; //商户支付宝账号 public static String seller_email = ""; //商户真实姓名 public static String account_name = " XXXXX科技有限公司"; // 支付宝用于提现的公钥,一般情况下无需修改该值 (提现),也可与支付宝map网关产品密钥的支付宝公钥做对比,应该是一致的。 public static String ali_public_key = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCnxj/9qwVfgoUh/y2W89L6BkRAFljhNhgPdyPuBV64bfQNN1PjbCzkIM6qRdKBoLPXmKKMiFYnkd6rAoprih3/PrQEB/VsW8OoM8fxn67UDYuyBTqA23MML9q1+ilIZwBC2AQ2UBVOrFXfFl75p6/B5KsiNG9zpgmLCUYuLkxpLQIDAQAB"; //商户安全校验码 public static String key = "MD5key"; //支付宝异步通知地址 需http://格式的完整路径,不允许加?id=123这类自定义参数 public static String notify_url = PathUtil.GetDemain() + "/WoBanAdmin/ALiPay/TransNotify"; //public static String notify_url = "http://xiaofanfight.viphk.ngrok.org/WoBanAdmin/ALiPay/TransNotify"; //↑↑↑↑↑↑↑↑↑↑请在这里配置您的基本信息↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ // 调试用,创建TXT日志文件夹路径 public static String log_path = "D:\\"; // 字符编码格式 目前支持 gbk 或 utf-8 public static String input_charset = "utf-8"; // 签名方式 不需修改(退款签名方式) public static String sign_type = "MD5"; }
代码里有很多是运用支付宝提供的Demo,支付宝并没有对这个接口提供jar包,公司的前辈也对此进行了开创性的研究,而我只是完善了一下,并写成了拙劣的文档。
提现申请列表 money_drawing.jsp
后台的流程及页面展示:
页面是位于pages - recharge 里的。
这里每进一次页面会有提示,因为很重要,所以每次都提醒;提示框的蓝色按钮可以跳往支付宝对证书的提示,它会说,目前只支持IE 32位部分浏览器以及UC浏览器,Mac下只支持safari;但我实际测试的时候,只支持火狐......支付宝需要证书是为了后面你输入支付宝支付密码时候的安全。
提现申请详情 BalanceDetail.jsp
一个的时候展示一个的详情,多人的时候展示多人的信息,点击结算后的页面是以弹出层展示的
点击结算后 HandleClearing.jsp
这里的话其实不加验证也可以的,毕竟支付宝那边还要验证支付密码,或者这里也可以结合一下管理员的手机号,进行处理。
输入密码,点击确定后,就走Controller了,从控制层里拼接一些参数,跳往支付宝页面。下面详述ALiPayController
实际上,在上一张图我们可知,能传过来的数据无非就几个:
1.哪个管理员操作的;
2.哪些人申请的提现。
因为像申请的钱什么的是依赖表的而不是页面上的数据。
参数ids实际上控制着三个选择,全部处理、按照选择处理、单个处理;然后一些基础信息的取得,计算提现的总额(依靠SQL),最为关键就是拼接参数,按照文档进行,参数中最为关键的就是 付款详细数据 以及 批次号和 流水号,批次号每次都是随机生成的,因为支付宝将相同的批次号视作同一笔申请,而流水号在App端提交的时候就做了处理;这里不需要关注一些细节,在支付宝提供的Demo里有涉及,有兴趣可以点进去看一看。/** * * <b>Method ALiPayTrans</b> * <dd>方法作用:请求批量转账前一步 * <dd>适用条件:提现 * <dd>使用方法:请参照最新的支付宝批量转账有密接口文档 * @param aid 管理员id * @param ids 提现申请人id的集合 * @param out 用于给支付宝回馈(大概吧) * @since Met 2.0 */ @RequestMapping(value = "ALiPayTrans", method = RequestMethod.GET) public void ALiPayTrans(Integer aid, String ids,PrintWriter out) { DecimalFormat df = new DecimalFormat(".00");//将double类型的数据保留两位数 List<Withdraw> wlist = new ArrayList<Withdraw>(); if(ids!=null && !ids.equals("")){ if(ids.equals("all")){//全部处理 wlist = withdrawMapper.selectWithdraw(new Withdraw()); }else{ if(ids.contains(",")){ String Ids=ids.trim().substring(0, ids.length() - 1); Withdraw withdraw = new Withdraw(); withdraw.setId(0); withdraw.setBatchNo(Ids); wlist = withdrawMapper.selectWithdraw(withdraw); }else{//单个流水 wlist = withdrawMapper.selectWithdraw(new Withdraw(Integer.parseInt(ids))); } } //服务器异步通知页面路径 通知提现成功与失败 String notify_url = AlipayConfig.notify_url; //付款账号 String email = AlipayConfig.seller_email; //付款账户名 String account_name = AlipayConfig.account_name; //必填,个人支付宝账号是真实姓名公司支付宝账号是公司名称 //付款当天日期 String pay_date = DateUtil.getDays(); //必填,格式:年[4位]月[2位]日[2位],如:20100801 //批次号 String batch_no = DateUtil.getDays() + DateUtil.getThree() + DateUtil.getThree(); //必填,格式:当天日期[8位]+序列号[3至16位],如:201008010000001 //付款总金额 //计算总额 BigDecimal summoney = withdrawMapper.querySumByGet(wlist); if(summoney != null && summoney.compareTo(new BigDecimal(10000000)) == -1){ String batch_fee = df.format(summoney).toString(); //必填,即参数detail_data的值中所有金额的总和 //付款笔数 Integer num = wlist.size(); String batch_num = num.toString(); //必填,即参数detail_data的值中,“|”字符出现的数量加1,最大支持1000笔(即“|”字符出现的数量999个) /* * 下列付款详细数据说明以及示例 * String detail_data = batch_no + "^" + "zhangsan@qq.com" + "^" + "张三" + "^" + batch_fee + "^备注说明"; * 解释:其中batch_no为上面生成的转账批次号;zhangsan@qq.com为需要转账的支付宝账户;张三为转账支付宝账户的真实姓名; * batch_fee为转账金额,最后的参数为附加参数,可以对本次转账备注说明,只要是字符串就可以,但长度不宜过长。请根据需要以此替换 */ //必填,即参数detail_data的值中,“|”字符出现的数量加1,最大支持1000笔(即“|”字符出现的数量999个) //付款详细数据 String detail_data = ""; for(int i=0;i<wlist.size();i++){ detail_data += wlist.get(i).getOnceid() + "^" + wlist.get(i).getAccount() + "^" + wlist.get(i).getRealname() + "^" + wlist.get(i).getMoney() + "^平台手续费"+wlist.get(i).getPlatform_cost()+"元"+"|"; } detail_data = detail_data.substring(0,detail_data.length()-1); //batch_no + "^" + "收款方账号" + "^" + "收款方真实姓名" + "^" + batch_fee + "^备注说明"; //必填,格式:流水号1^收款方帐号1^真实姓名^付款金额1^备注说明1|流水号2^收款方帐号2^真实姓名^付款金额2^备注说明2.... //修改提现信息 for(int j=0;j<wlist.size();j++){ Withdraw mapper = new Withdraw(wlist.get(j).getId(), batch_no, aid); mapper.setState(2); withdrawMapper.updateWithdraw(mapper); } //把请求参数打包成数组 Map<String, String> sParaTemp = new HashMap<String, String>(); sParaTemp.put("service", "batch_trans_notify"); sParaTemp.put("partner", AlipayConfig.partner); sParaTemp.put("_input_charset", AlipayConfig.input_charset); sParaTemp.put("notify_url", notify_url); sParaTemp.put("email", email); sParaTemp.put("account_name", account_name); sParaTemp.put("pay_date", pay_date); sParaTemp.put("batch_no", batch_no); sParaTemp.put("batch_fee", batch_fee); sParaTemp.put("batch_num", batch_num); sParaTemp.put("detail_data", detail_data); //建立请求 // Log log = new Log(aid,"处理提现申请操作,处理额度为" + summoney + "元"); logMa.insert(log); String sHtmlText = AlipaySubmit.buildRequest(sParaTemp, "post", "确认"); out.println(sHtmlText); }else{ out.print("<script>alert('处理批次额度超限。最高不超过1000万元。');</script>"); } } }
顺利就跳到了以下的页面,值得注意的是,在这个Controller内,将这笔提现申请改成了处理中,避免其他人会重复处理。请注意这个状态。
支付宝页面
支付宝提示当前操作环境不支持支付宝控件,因为我是谷歌浏览器打开的,这种情况支付宝不会给异步通知,因为它不知道是这种问题,它会通知的情况只有两种:
1.支付成功;
2.支付失败,给转账的支付宝用户信息不正确。
所以处理中的作用就是存放意外情况下的申请,管理员可以在合适的环境下,将处理中的申请再次转化为申请中,再次提交。
申请中列表
支付失败之一 收款支付宝账户信息校验不通过
以下为出现意外的情况截图:
不会通知的情况 未安装证书
上一步安装了之后有刷新选项,但是支付宝会这样说,也不需要管,因为刚刚那笔订单已经在提现申请中里了
不会通知的情况 安装了证书之后未安装电子证书
正常情况下:
安装了电子证书 正常的情况1
下面这个图就说明了,支付宝会给打款成功的回调。
安装了电子证书 正常的情况2
支付宝的异步通知
刚刚问了半天客服,客服也无法说清楚,建议是在IE8 32位浏览器下进行的,可是IE8会和layui会有冲突,这就有点尴尬了。以上的截图是在火狐下进行的。客服也不置可否。
完成了支付宝付款的逻辑,就剩下接收回调的处理了,也在Controller层,对于成功走成功逻辑,失败走失败逻辑。
这里关注一下支付宝的说明即可:如果成功的信息为空,证明都失败了,反之;如果两者都不为空,就需要各自走各自的逻辑了,根据流水号查出提现的详情,成功推送信息,失败返回资金并推送。
回调处理的关键@RequestMapping(value = "TransNotify", method = RequestMethod.POST) /** * 批量付款数据中转账成功的详细信息 String success_details * 批量付款数据中转账失败的详细信息 String fail_details * 批量付款数据中转账批次号 String batch_no */ public void TransNotify(HttpServletRequest request, String success_details, String batch_no, String fail_details, PrintWriter out) { //获取支付宝POST过来反馈信息 Map<String, String> params = GetInfoFromALiPay(request); boolean flag = true; if(AlipayNotify.verify(params)){ //验证成功 //判断是否在商户网站中已经做过了这次通知返回的处理;如果没有做过处理,那么执行商户的业务程序;如果有做过处理,那么不执行商户的业务程序 //可以判断success_details是否为null来标识转账是否成功,支付宝方面明确说明如果转账成功success_details不为null,fail_details则 //为null;若转账失败success_details为null而fail_details不为null,同样根据batch_no来查询转账对象并更新转账状态 if(fail_details == null){ //提现全部成功 处理相关业务 看是否已经处理过了 改状态 推送 withdrawService.getMoneySuccess(batch_no); }else if(success_details == null){ //提现全部失败 //返还未提现用户的金币 推送 String info = withdrawService.getMoneyError(batch_no); if("error".equals(info)){ flag = false;//自己sql出错,请求支付宝再次发送验证 } }else{ //转账部分成功/部分失败 String info = withdrawService.getMoneySuccessOrError(batch_no, success_details, fail_details); if("error".equals(info)){ flag = false;//自己sql出错,请求支付宝再次发送验证 } } if(flag){ out.print("success");//请勿修改该值! }else{ out.print("fail");//自己sql出错,请求支付宝再次发送验证 } }else{ //验证失败 //程序执行完后必须打印输出“success”(不包含引号)。如果商户反馈给支付宝的字符不是success这7个字符,支付宝服务器会不断重发通知,直到超过24小时22分钟 out.print("fail"); } } @SuppressWarnings("unchecked") public Map<String, String> GetInfoFromALiPay(HttpServletRequest request) { Map<String, String> params = new HashMap<String, String>(); Map requestParams = request.getParameterMap(); for (Iterator iter = requestParams.keySet().iterator(); iter.hasNext();) { String name = (String) iter.next(); String[] values = (String[]) requestParams.get(name); String valueStr = ""; for (int i = 0; i < values.length; i++) { valueStr = (i == values.length - 1) ? valueStr + values[i] : valueStr + values[i] + ","; } //乱码解决,这段代码在出现乱码时使用。如果mysign和sign不相等也可以使用这段代码转化 //valueStr = new String(valueStr.getBytes("ISO-8859-1"), "gbk"); params.put(name, valueStr); } return params; }
5. 结束
第一次总结,文字还是很冗余的。在这里还是需要感谢一下ydm公司的前辈。写于2017-03-17。
网友评论