[Day 18] - 初探永丰银行线上收款API - 丰收款 - 建立订单!

一转眼已经到第18天了,照这个速度可能没办法完成一个网站,今天要来赶进度!

首先要勘误

在nonce取值的部分,
我原本是直接return取得nonce的api回传的value,
https://ithelp.ithome.com.tw/upload/images/20211003/20128973GVhiZN3RrX.png
但回传的是json格式
所以要把先前写的getNonce()方法小改一下,加上parse json的部分

另外getHashID()的return应该要转大写,补上:

return hashID.toUpperCase();

再来是getSign()的部分我忘了被加密的内容要先加上nonce跟Hash ID/images/emoticon/emoticon02.gif
以上我在
[Day 15] - 初探永丰银行线上收款API - 丰收款 - HASH ID计算(2) +IV计算
[Day 17] - 初探永丰银行线上收款API - 丰收款 - Sign值计算(2)
做了更新

另外纪录一下今天踩到的一颗雷,
在测试时,发现在相同nonce、hash id、内文的情况下,我算出的sign值竟然跟永丰提供的加解密计算机算出的结果不一样...
我用加解密计算机产生的request去发送api,
明明json的顺序、大小写都没影响呀...

但是相同的内容,回到我的程序,api却回

E0000 – 安全签章错误
/images/emoticon/emoticon10.gif

测了老半天才发现

是大小写的问题

ObjectMapper会将我原本写好的物件名称转成小写,
例如Amount会变成amount,
所以我的内文会变这样

{"amount":55000,
"backendURL":"http://yahoo.com.tw",
"cardParam":{"autoBilling":"Y"},
"currencyID":"TWD",
"orderNo":"C2021000000022",
"payType":"C",
"prdtName":"信用卡订单123",
"returnURL":"http://google.com",
"shopNo":"NA9999_999"}

我原本以为没影响,因为把以上内文放到加解密计算机得出的request,是可以成功建立订单的

但是事情没我想得这麽简单
把内文输入到加解密计算机
它在计算sign会进行以下动作
https://ithelp.ithome.com.tw/upload/images/20211003/20128973hFEgjWoXO8.png
在步骤2,并不只是单纯的将各栏位转成 PropertyName=Value 的格式
而且还有将各栏位的PropertyName转为规格书中定义的名称,
也就是我原本给的amount,会被它转为Amount,
经过这样一转,SHA256的值也就不一样了
因为之前没发现加解密计算机有多做这一动,所以我还以为大小写不影响/images/emoticon/emoticon02.gif

永丰api那边,我猜也是会在message解密後,将内文栏位转为正确的大小写,再拿来计算sign
并比对我们给的sign跟它算出的sign是否相同
因此我们在计算sign时,要先想到永丰会以大小写正确的内文栏位名称来计算sign

简而言之,我的栏位名称要跟规格书上的大小写一致啦

这边我参考stackoverflow上的做法

增加一个QpayPropNamingStrategy.java

import com.fasterxml.jackson.databind.PropertyNamingStrategy;
import com.fasterxml.jackson.databind.cfg.MapperConfig;
import com.fasterxml.jackson.databind.introspect.AnnotatedField;
import com.fasterxml.jackson.databind.introspect.AnnotatedMethod;

public class QpayPropNamingStrategy extends PropertyNamingStrategy {
    @Override
    public String nameForField(MapperConfig<?> config, AnnotatedField field, String defaultName) {
        return convert(field.getName());
    }

    @Override
    public String nameForGetterMethod(MapperConfig<?> config, AnnotatedMethod method, String defaultName) {
        return convert(method.getName().toString());
    }

    @Override
    public String nameForSetterMethod(MapperConfig<?> config, AnnotatedMethod method, String defaultName) {
        return convert(method.getName().toString());
    }

    private String convert(String input) {
        return input.substring(3);
    }
}

这边大概是藉由取得方法名称,把前面的get去掉,得出第一个字是大写的名字,我觉得还满聪明的

接着在使用ObjectMapper前,先套用mapper.setPropertyNamingStrategy(new MyPropertyNamingStrategy());就行了
现在大小写都一致了,

{"Amount":55000,
"BackendURL":"http://yahoo.com.tw",
"CardParam":{"AutoBilling":"Y"},
"CurrencyID":"TWD",
"OrderNo":"C000043000022",
"PayType":"C",
"PrdtName":"信用卡订单123",
"ReturnURL":"http://google.com",
"ShopNo":"NA00001_001"}

这样终於能成功建立订单了/images/emoticon/emoticon02.gif

回顾一下,整个orderCreate方法是长这样

public String orderCreate(OrderCreateReq orderCreateReq){

    //1.nonce
    String nonce=getNonce();
    //2.hashID
    String hashID=getHashID();
    //3.iv
    String iv = getIV(nonce);

    ObjectMapper mapper = new ObjectMapper();
    mapper.setPropertyNamingStrategy(new QpayPropNamingStrategy());
    Map<String, Object> map = 
    mapper.convertValue(orderCreateReq, new TypeReference<TreeMap<String, Object>>() {});
    //remove null
    map.values().removeIf(Objects::isNull);
    map.forEach((k, v) -> {
        if(v.getClass().equals(java.util.LinkedHashMap.class)){
            LinkedHashMap<Object,Object>m =(LinkedHashMap<Object, Object>) v;
            m.values().removeIf(Objects::isNull);
            map.replace(k, v, m);
        }
    });

    String jsonContent ="";
    try {
        jsonContent = new ObjectMapper().writeValueAsString(map);
    } catch (JsonProcessingException e) {
        e.printStackTrace();
    }

    //4.sign
    String sign = getSign(map,nonce,hashID);

    //5.message
    String message = "";
        try {
            message= encryptUtil.encrypt(jsonContent, hashID, iv);
        } catch (IOException | GeneralSecurityException e) {
            e.printStackTrace();
        }

    //send to api
    Map<String,String> request = new HashMap<String,String>();
    request.put("Version", "1.0.0");
    request.put("ShopNo", "NA0040_001");
    request.put("APIService", "OrderCreate");
    request.put("Sign", sign);        
    request.put("Nonce", nonce);
    request.put("Message", message);

    String reqJson="";
    try {
        reqJson = new ObjectMapper().writeValueAsString(request);
    } catch (JsonProcessingException e) {
        e.printStackTrace();
    }
    String res="";
    try {
        res=util.post("https://apisbx.sinopac.com/funBIZ/QPay.WebAPI/api/Order", reqJson);
    } catch (IOException e) {
        // TODO Auto-generated catch block
        e.printStackTrace();
    }

    return res;
}

发送订单到API後,api一样会回覆json字串,
也就是上面返回的res,
但api返回的字串也是有加密的,
接下来要进行response的解密跟sign验证
为了要AES解密,到先前创建的EncryptUtil.java增加一个decrypt方法
大概是这样

    public String decrypt(String hexContent, String key,String iv) throws IOException,GeneralSecurityException, DecoderException {

        byte[] raw = key.getBytes("UTF-8");
        SecretKeySpec keySpec = new SecretKeySpec(raw, "AES");
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        IvParameterSpec ips = new IvParameterSpec(iv.getBytes("UTF-8"));
        cipher.init(Cipher.DECRYPT_MODE,keySpec,ips);

        byte[] encrypted = Hex.decodeHex(hexContent.toCharArray());
        byte[] origin = cipher.doFinal(encrypted);
        String string = new String(origin);

        return string;

    }

回到orderCreate方法
这边为了方便我直接将api回传的response转换成Map,
将sign、nonce、message取出
message一样是以AES CBC加密,
为了解密需使用一样的Key(Hash ID)、IV(由回传的NONCE来计算)
如果用request使用的IV,会没办法顺利解密

...前略

String res="";
try {
    res=util.post("https://apisbx.sinopac.com/funBIZ/QPay.WebAPI/api/Order", reqJson);
} catch (IOException e) {
    e.printStackTrace();
}

Map<String, String> resMap =new HashMap<String,String>();
try {
    resMap = mapper.readValue(res, new TypeReference<HashMap<String, String>>() {});
} catch (JsonProcessingException e) {
    e.printStackTrace();
}

String resSign =resMap.get("Sign");
String resNonce =resMap.get("Nonce");
String resMessage =resMap.get("Message");
System.err.println(resMessage);

String resiv= getIV(resNonce);

try {
    resMessage=encryptUtil.decrypt(resMessage, hashID, resiv);
} catch (IOException | GeneralSecurityException | DecoderException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
}

...

取得解密後的res,到这边暂停,
先去建立一个OrderCreateRes.java,定义好资料类别

import lombok.Data;
@Data
public class OrderCreateRes {
    public enum PayType { A, C };
    public enum Status {S,F};

    
    @Data
    public class ATMParam{
        private String AtmPayNo;
        private String WebAtmURL;
        private String OtpUrl;
    }
    @Data
    public class CardParam{
        private String CardPayURL;
    }
    
    private String OrderNo;
    private String ShopNo;
    private String TSNo;
    private Integer Amount;
    private Status Status;
    private String Description;
    private String Param1;
    private String Param2;
    private String Param3;
    private PayType PayType;
    private ATMParam ATMParam;
    private CardParam CardParam;

}

回到orderCreate
在取得解密完成的resMessage後,因为这也是json字串,所以我先转成Map,使其可以适用先前写的getSign方法

Map<String, Object> resMessageMap =new TreeMap<String,Object>();
try {
    resMessageMap = mapper.readValue(resMessage, new TypeReference<TreeMap<String, Object>>() {});
} catch (JsonProcessingException e) {
    e.printStackTrace();
}

取得自行计算的Sign来跟api回传时附带的Sign做比较,如果一样就表示内容没被窜改

        String resForUser = "";
        String signCheck = getSign(resMessageMap, resNonce, hashID);

        if(signCheck.equals(resSign)){
            System.out.println("Sign Check OK:"+signCheck);
        }else{
            resForUser="永丰回传签章验证失败"+"\nA:"+signCheck+"\nresSign:"+resSign;
            return resForUser;
        }

接着,就可以把解密完的内容套用至刚刚定义好的资料型别OrderCreateRes,做个简单的if判断,先回传必要资讯就好,到时候有需要再做调整

        OrderCreateRes orderCreateRes = new OrderCreateRes();
        try {
            orderCreateRes = mapper.readValue(resMessage, OrderCreateRes.class);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }

        if(orderCreateRes.getATMParam()!=null){
            resForUser = orderCreateRes.getATMParam().getAtmPayNo();
        }else if(orderCreateRes.getCardParam()!=null){
            resForUser = orderCreateRes.getCardParam().getCardPayURL(); 
        }else if(orderCreateRes.getDescription()!=null){
            resForUser = orderCreateRes.getDescription();
        }else {
            resForUser = "不明错误,请联系网站负责人";
        }

        return resForUser;

传送信用卡订单,会回传一个信用卡付款页面url
https://ithelp.ithome.com.tw/upload/images/20211003/20128973TUUXgJVaXV.png

大概就是这样了,原本今天要赶进度的
结果花在debug的时间比我想像的还要长,这边解决後,那边又发现有问题,串api果然不是轻松的事...
另外,我自己觉得在程序中用了很多Map到近乎有点滥用,之後有时间的话再想想有没有更好的做法

今天就先到此结束!

目前待办清单有:
1.继续实作收款api的其他功能
2.JWT调整(过期前应该要不断重发新的JWT给client,不然client端每30分钟要重新登入一次)
3.前端React的撰写
4.用React写的前端页面串接汇率api
5.把收款API的Log纪录到DB
6.整理code、把参数放到DB、改用Spring IOC的写法...etc

明天可能会挑其中一个来做/images/emoticon/emoticon06.gif


<<:  [Day20] - Django-REST-Framework Serializers 介绍

>>:  [DAY 28] 用google sheet 做简易UI介面(3/3)

DAY21 搞样式--CSS Gird小进阶(Template Area)

前言 今天我们一样使用上篇的格线布局作为范例 假设我们现在想在容器(Container)中放入三个元...

C#语言和你 SAY HELLO!!

第五天 各位点进来的朋友,你们好阿 小的不才只能做这个系列的文章,但还是希望分享给点进来的朋友,知道...

Day15-Nginx 限制访问来源

Nginx 直接写在 config 内 location / { allow 127.0.0.0/2...

Day 3:747. Largest Number At Least Twice of Others

今日题目 题目连结:747. Largest Number At Least Twice of Ot...

[区块链&DAPP介绍 Day8] Solidity 教学 - functions

今日来介绍 function 的语法结构 function 里面有三个很特别的保留字分别是 pure...