车载码初步实现
diff --git a/payapi/build.gradle b/payapi/build.gradle
index 76f80e3..0a7d16c 100644
--- a/payapi/build.gradle
+++ b/payapi/build.gradle
@@ -104,6 +104,7 @@
     implementation 'log4j:log4j:1.2.17'
     implementation 'com.alibaba:fastjson:1.2.60'
 
+    implementation 'com.eatthepath:java-otp:0.1.0'
     implementation project(':payapi-common')
     /*支付宝SDK*/
     implementation group: 'com.alipay.sdk', name: 'alipay-sdk-java', version: '3.7.110.ALL'
diff --git a/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/BinUtil.java b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/BinUtil.java
new file mode 100644
index 0000000..5b8f43b
--- /dev/null
+++ b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/BinUtil.java
@@ -0,0 +1,67 @@
+package com.supwisdom.dlpay.busQRcode;
+
+import org.apache.commons.codec.binary.Base64;
+
+public class BinUtil {
+  public static byte[] decodeHex(String data) {
+    if (data == null || data.length() % 2 != 0) {
+      throw new RuntimeException("decodeHex data length must be divided by 2");
+    }
+    byte[] result = new byte[data.length() / 2];
+    for (int i = 0; i < data.length(); i += 2) {
+      result[i / 2] = (byte) Integer.parseInt(data.substring(i, i + 2), 16);
+    }
+    return result;
+  }
+
+  public static String encodeHex(byte[] data) {
+    if (data == null) {
+      throw new RuntimeException("encodeHex data must be not null");
+    }
+    StringBuilder sb = new StringBuilder();
+    for (byte datum : data) {
+      sb.append(String.format("%02x", datum & 0xFF));
+    }
+    return sb.toString();
+  }
+
+  public static byte[] encodeBCD(String data) {
+    if (data == null || data.length() % 2 != 0) {
+      throw new RuntimeException("encodeBCD data length must be divided by 2");
+    }
+    byte[] result = new byte[data.length() / 2];
+    int base = (int)'0';
+    for (int i = 0; i < data.length(); i += 2) {
+      int t = (int)data.charAt(i) - base;
+      int t1 = (int)data.charAt(i + 1) - base;
+      if (t < 0 || t > 9 || t1 < 0 || t1 > 9) {
+        throw new RuntimeException("encodeBCD char must be '0'~'9'");
+      }
+      result[i / 2] = (byte) (((t << 4) | t1) & 0xFF);
+    }
+    return result;
+  }
+
+  public static String decodeBCD(byte[] data) {
+    if (data == null) {
+      throw new RuntimeException("decodeBCD data must be not null");
+    }
+    int base = (int)'0';
+    StringBuilder sb = new StringBuilder();
+    for (byte datum : data) {
+      int t1 = ((datum >> 4) & 0x0F) + base;
+      int t2 = (datum & 0x0F) + base;
+      sb.append((char) t1)
+          .append((char) t2);
+    }
+    return sb.toString();
+  }
+
+  public static String encodeBase64(byte[] data) {
+    return Base64.encodeBase64String(data);
+  }
+
+  public static byte[] decodeBase64(String data) {
+    return Base64.decodeBase64(data);
+  }
+}
diff --git a/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/CryptoUtil.java b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/CryptoUtil.java
new file mode 100644
index 0000000..75c4d08
--- /dev/null
+++ b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/CryptoUtil.java
@@ -0,0 +1,117 @@
+package com.supwisdom.dlpay.busQRcode;
+
+import javax.crypto.*;
+import javax.crypto.spec.DESKeySpec;
+import javax.crypto.spec.DESedeKeySpec;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.KeySpec;
+import java.util.Arrays;
+
+public class CryptoUtil {
+
+  private static byte[] doCipher(Cipher cipher, SecretKey key, int mode, byte[] data) {
+    try {
+      cipher.init(mode, key);
+      return cipher.doFinal(data);
+    } catch (InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
+      e.printStackTrace();
+    }
+    return null;
+  }
+
+  private static byte[] doDesCipher(byte[] key, int cipherMode, byte[] data) {
+    try {
+      String mode;
+      KeySpec keySpec;
+      if (key.length == 8) {
+        mode = "DES";
+        keySpec = new DESKeySpec(key);
+      } else if (key.length == 16) {
+        mode = "DESede";
+        byte[] keyEDE = new byte[24];
+        System.arraycopy(key, 0, keyEDE, 0, 16);
+        System.arraycopy(key, 0, keyEDE, 16, 8);
+        keySpec = new DESedeKeySpec(keyEDE);
+      } else {
+        throw new RuntimeException("DES Key length must be 8 or 16 bytes");
+      }
+      Cipher keyCipher = Cipher.getInstance(mode + "/ECB/NoPadding");
+      SecretKey secret = SecretKeyFactory.getInstance(mode).generateSecret(keySpec);
+      return doCipher(keyCipher, secret, cipherMode, data);
+    } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeySpecException | InvalidKeyException e) {
+      e.printStackTrace();
+    }
+    return null;
+  }
+
+  public static byte[] desEncryptECB(byte[] key, byte[] plain) {
+    return doDesCipher(key, Cipher.ENCRYPT_MODE, plain);
+  }
+
+  public static byte[] desDecryptECB(byte[] key, byte[] cipher) {
+    return doDesCipher(key, Cipher.DECRYPT_MODE, cipher);
+  }
+
+  private static byte[] messageDigest(String alg, byte[] data) {
+    try {
+      MessageDigest md = MessageDigest.getInstance(alg);
+      md.update(data);
+      return md.digest();
+    } catch (NoSuchAlgorithmException e) {
+      e.printStackTrace();
+    }
+    return null;
+  }
+
+  public static byte[] md5(byte[] data) {
+    return messageDigest("MD5", data);
+  }
+
+  public static byte[] sha256(byte[] data) {
+    return messageDigest("SHA-256", data);
+  }
+
+  public static byte[] sha1(byte[] data) {
+    return messageDigest("SHA-1", data);
+  }
+
+  private static byte[] doAESCipher(byte[] key, byte[] iv, byte[] data, int mode) {
+    try {
+      Cipher keyCipher = Cipher.getInstance("AES/CFB/NoPadding");
+      IvParameterSpec ivSpec = new IvParameterSpec(iv);
+      SecretKeySpec secret = new SecretKeySpec(key, "AES");
+      keyCipher.init(mode, secret, ivSpec);
+      return keyCipher.doFinal(data);
+    } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException
+        | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
+      e.printStackTrace();
+    }
+    return null;
+  }
+
+  public static byte[] aesEncryptCFB(byte[] key, byte[] plain) {
+    byte[] iv = new byte[16];
+    Arrays.fill(iv, (byte) 0);
+    return aesEncryptCFB(key, plain, iv);
+  }
+
+  public static byte[] aesEncryptCFB(byte[] key, byte[] plain, byte[] iv) {
+    return doAESCipher(key, iv, plain, Cipher.ENCRYPT_MODE);
+  }
+
+  public static byte[] aesDecryptCFB(byte[] key, byte[] cipher, byte[] iv) {
+    return doAESCipher(key, iv, cipher, Cipher.DECRYPT_MODE);
+  }
+
+  public static byte[] aesDecryptCFB(byte[] key, byte[] cipher) {
+    byte[] iv = new byte[16];
+    Arrays.fill(iv, (byte) 0);
+    return aesDecryptCFB(key, cipher, iv);
+  }
+}
diff --git a/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/PbocAlgorithem.java b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/PbocAlgorithem.java
new file mode 100644
index 0000000..f63b4f1
--- /dev/null
+++ b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/PbocAlgorithem.java
@@ -0,0 +1,93 @@
+package com.supwisdom.dlpay.busQRcode;
+
+import java.util.Arrays;
+
+import static com.supwisdom.dlpay.busQRcode.CryptoUtil.desDecryptECB;
+import static com.supwisdom.dlpay.busQRcode.CryptoUtil.desEncryptECB;
+
+
+public class PbocAlgorithem {
+  private static final byte[] PADDING = new byte[]{(byte) 0x80, 0, 0, 0, 0, 0, 0, 0};
+
+  private static byte[] doDeliveryKey(byte[] key, byte[] factor) {
+    if (key.length == 16) {
+      byte[] factor1 = new byte[8];
+      for (int i = 0; i < 8; i++) {
+        factor1[i] = (byte) ((~((int) factor[i] & 0xFF)) & 0xFF);
+      }
+      byte[] d1 = desEncryptECB(key, factor);
+      byte[] d2 = desEncryptECB(key, factor1);
+      byte[] result = new byte[16];
+      System.arraycopy(d1, 0, result, 0, 8);
+      System.arraycopy(d2, 0, result, 8, 8);
+      return result;
+    } else if (key.length == 8) {
+      return desEncryptECB(key, factor);
+    } else {
+      throw new RuntimeException("Des key length must be 8 or 16");
+    }
+  }
+
+  public static byte[] deliveryKey(byte[] rootKey, byte[] factory) {
+    if (factory.length % 8 != 0) {
+      throw new RuntimeException("delivery key factory length must be divived by 8");
+    }
+    if (factory.length / 8 > 3) {
+      throw new RuntimeException("delivery key factor length must be less or equal 24");
+    }
+
+    byte[] deliveryKey = rootKey;
+
+    for (int i = 0; i < factory.length; i += 8) {
+      deliveryKey = doDeliveryKey(deliveryKey, Arrays.copyOfRange(factory, i, i + 8));
+    }
+    return deliveryKey;
+  }
+
+  private static byte[] doDesMac(byte[] key, byte[] init, byte[] data) {
+    byte[] temp = Arrays.copyOf(init, 8);
+    for (int offset = 0; offset < data.length; offset += 8) {
+      int i;
+      for (i = 0; i < 8 && i + offset < data.length; ++i) {
+        temp[i] = (byte) (((int) temp[i] & 0xFF) ^ ((int) data[i + offset] & 0xFF));
+      }
+      for (int j = i; i < 8; ++i) {
+        temp[i] = (byte) (((int) temp[i] & 0xFF) ^ ((int) PADDING[i - j] & 0xFF));
+      }
+      temp = desEncryptECB(key, temp);
+    }
+    return temp;
+  }
+
+  public static byte[] desMac(byte[] key, byte[] init, byte[] data) {
+    byte[] buffer = Arrays.copyOfRange(doDesMac(key, init, data), 0, 4);
+    if (data.length % 8 == 0) {
+      buffer = doDesMac(key, buffer, PADDING);
+    }
+    return Arrays.copyOfRange(buffer, 0, 4);
+  }
+
+  public static byte[] desMac(byte[] key, byte[] data) {
+    byte[] init = new byte[8];
+    Arrays.fill(init, (byte) 0);
+    return desMac(key, init, data);
+  }
+
+  public static byte[] des3Mac(byte[] key, byte[] init, byte[] data) {
+    byte[] k1 = Arrays.copyOfRange(key, 0, 8);
+    byte[] k2 = Arrays.copyOfRange(key, 8, 16);
+    byte[] buffer = doDesMac(k1, init, data);
+    if (data.length % 8 == 0) {
+      buffer = doDesMac(key, buffer, PADDING);
+    }
+    buffer = desDecryptECB(k2, buffer);
+    buffer = desEncryptECB(k1, buffer);
+    return Arrays.copyOfRange(buffer, 0, 4);
+  }
+
+  public static byte[] des3Mac(byte[] key, byte[] data) {
+    byte[] init = new byte[8];
+    Arrays.fill(init, (byte) 0);
+    return des3Mac(key, init, data);
+  }
+}
diff --git a/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/QrCode.java b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/QrCode.java
new file mode 100644
index 0000000..35a50e0
--- /dev/null
+++ b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/QrCode.java
@@ -0,0 +1,280 @@
+package com.supwisdom.dlpay.busQRcode;
+
+import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator;
+import com.supwisdom.dlpay.util.TOTP;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+import static com.supwisdom.dlpay.busQRcode.BinUtil.*;
+import static com.supwisdom.dlpay.busQRcode.CryptoUtil.*;
+
+public class QrCode {
+  private static Logger logger = LoggerFactory.getLogger(QrCode.class);
+
+  public static final String DELIMITER = ":";
+  public static final String SCOPE_DELIMITER = ",";
+  public static final String DEFAULT_SCOPE = "1"; //默认仅支持 1-大理车载消费场景
+  public static final int TOTP_OFFSET = 30;
+
+//  static final ArrayList<String> DEFAULT_SCOPES = new ArrayList<>();
+//  static {
+//    DEFAULT_SCOPES.add("1"); //默认仅支持 1-大理车载消费场景
+//  }
+
+  private Builder qrBuilder;
+
+  private QrCode(Builder b) {
+    this.qrBuilder = b;
+  }
+
+  public static class Builder {
+    private String appid;
+    private String uid;
+    private String scope;
+    private String seed = "a8aae1a41ffab089428e8e21381cc34af3b614ac7b7cba50f58470b8e50c6072";
+    private boolean resetSeed = false;
+    private byte[] iv = decodeHex("55b6f5b3287c535f8274a99354676d0b");
+    private boolean resetIv = false;
+    private byte[] rootKey;
+    private byte[] sKey;
+    private byte[] des3Key;
+    private String prefix;
+    private boolean debug;
+
+    public QrCode create() {
+      return new QrCode(this);
+    }
+
+    public Builder appid(String appid) {
+      if (StringUtils.isBlank(appid) || !StringUtils.isAsciiPrintable(appid)) {
+        throw new RuntimeException("appid is invalid");
+      }
+      this.appid = appid;
+      this.des3Key = md5(appid.getBytes());
+      return this;
+    }
+
+    public Builder uid(String uid) {
+      if (StringUtils.isBlank(uid) || !StringUtils.isAsciiPrintable(uid)) {
+        throw new RuntimeException("uid is invalid");
+      }
+      this.uid = uid;
+      return this;
+    }
+
+    public Builder scope(String scope) {
+      if (StringUtils.isBlank(scope)) {
+        throw new RuntimeException("scope is empty");
+      }
+      this.scope = scope;
+      return this;
+    }
+
+    public Builder rootkey(String key) {
+      this.rootKey = decodeHex(key);
+      return this;
+    }
+
+    public Builder rootkey(String appid, String skey){
+      if (StringUtils.isBlank(appid) || !StringUtils.isAsciiPrintable(appid)) {
+        throw new RuntimeException("appid is invalid");
+      }
+      if (StringUtils.isBlank(skey)) {
+        throw new RuntimeException("sKey is empty");
+      }
+      this.appid = appid;
+      this.sKey = decodeHex(skey);
+      this.des3Key = md5(appid.getBytes());
+      this.rootKey = desEncryptECB(this.des3Key, this.sKey);
+      return this;
+    }
+
+    public Builder seed(String s) {
+      if (StringUtils.isBlank(s)) {
+        throw new RuntimeException("seed is empty");
+      }
+      this.seed = s;
+      this.resetSeed = true;
+      return this;
+    }
+
+    public Builder ivHex(String v) {
+      this.iv = decodeHex(v);
+      this.resetIv = true;
+      return this;
+    }
+
+    public Builder prefix(String p) {
+      if (p == null) {
+        throw new RuntimeException("prefix must not be null");
+      }
+      this.prefix = p;
+      return this;
+    }
+
+    public Builder debug(boolean on) {
+      this.debug = on;
+      return this;
+    }
+  }
+
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  private static String getRandomString(int length) {
+    String base = "abcdefghijklmnopqrstuvwxyz234567";
+    Random random = new Random(System.currentTimeMillis());
+    StringBuilder sb = new StringBuilder();
+    for (int i = 0; i < length; i++) {
+      int number = random.nextInt(base.length());
+      sb.append(base.charAt(number));
+    }
+    return sb.toString();
+  }
+
+  private String genTOTPWithSeed(String seed, int pl) {
+    final TimeBasedOneTimePasswordGenerator alg = totpGenerator(pl);
+    final SecretKey secretKey = new SecretKeySpec(decodeHex(seed),
+        TimeBasedOneTimePasswordGenerator.TOTP_ALGORITHM_HMAC_SHA256);
+    try {
+      Date now = Calendar.getInstance().getTime();
+      return StringUtils.leftPad(String.format("%d", alg.generateOneTimePassword(secretKey, now)),
+          pl, '0');
+    } catch (InvalidKeyException e) {
+      throw new RuntimeException("TOTP Error", e);
+    }
+  }
+
+  private TimeBasedOneTimePasswordGenerator totpGenerator(int passwordLength) {
+    try {
+      return new TimeBasedOneTimePasswordGenerator(30, TimeUnit.SECONDS, passwordLength,
+          TimeBasedOneTimePasswordGenerator.TOTP_ALGORITHM_HMAC_SHA256);
+    } catch (NoSuchAlgorithmException e) {
+      throw new RuntimeException("TOTP Error", e);
+    }
+  }
+
+  private boolean checkScope(List<String> scopes) {
+    return scopes.contains(DEFAULT_SCOPE);
+  }
+
+  private boolean verifyTotp(String totp, String secret, int offset) {
+    String second = "30";
+    String seed = secret;
+    long T0 = 0;
+    String[] keys = new String[offset * 2 + 1];
+    long time = Calendar.getInstance().getTime().getTime() / 1000;
+    for (int i = 0; i < keys.length; i++) {
+      String steps = "0";
+      try {
+        long T = (time - T0) / Long.parseLong(second);
+        steps = Long.toHexString(T + (i - offset)).toUpperCase();
+        while (steps.length() < 16) {
+          steps = "0" + steps;
+        }
+        String key = TOTP.generateTOTP(secret, steps, "8", TimeBasedOneTimePasswordGenerator.TOTP_ALGORITHM_HMAC_SHA256);
+        keys[i] = key;
+      } catch (final Exception e) {
+        System.out.println("Error : " + e);
+      }
+    }
+    for (String key : keys) {
+      if (key.equals(totp)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  public String qrcode() {
+    if (qrBuilder.debug) {
+      logger.info("appid=" + qrBuilder.appid);
+      logger.info("uid=" + qrBuilder.uid);
+      logger.info("scope=" + qrBuilder.scope);
+      if (qrBuilder.resetSeed) logger.info("seed=" + qrBuilder.seed);
+      if (qrBuilder.resetIv) logger.info("iv=" + encodeHex(qrBuilder.iv));
+      if (null != qrBuilder.sKey) logger.info("sKey=" + encodeHex(qrBuilder.sKey));
+      if (null != qrBuilder.prefix) logger.info("prefix=" + qrBuilder.prefix);
+
+      logger.info("rootkey=" + encodeHex(qrBuilder.rootKey));
+      logger.info("=======================================================");
+    }
+
+    final String totp = genTOTPWithSeed(qrBuilder.seed, 8);
+    final String encDataPlain = new StringJoin(DELIMITER)
+        .add(qrBuilder.uid)
+        .add(qrBuilder.scope)
+        .add(totp).toString();
+
+    final byte[] encData = aesEncryptCFB(qrBuilder.rootKey, encDataPlain.getBytes(), qrBuilder.iv);
+    final String code = encodeBase64(encData);
+    if (qrBuilder.debug) {
+      logger.info("encdata plain= " + encDataPlain);
+    }
+
+    if (qrBuilder.prefix != null) {
+      return qrBuilder.prefix + code;
+    }
+    return code;
+  }
+
+  public String decodeUid(String qrcode) {
+    if (qrBuilder.debug) {
+      logger.info("appid=" + qrBuilder.appid);
+      if (qrBuilder.resetSeed) logger.info("seed=" + qrBuilder.seed);
+      if (qrBuilder.resetIv) logger.info("iv=" + encodeHex(qrBuilder.iv));
+      if (null != qrBuilder.sKey) logger.info("sKey=" + encodeHex(qrBuilder.sKey));
+      if (null != qrBuilder.prefix) logger.info("prefix=" + qrBuilder.prefix);
+      logger.info("=======================================================");
+    }
+    if (null == qrBuilder.rootKey) {
+      throw new RuntimeException("qr decode root key must not be null");
+    }
+    if (qrBuilder.prefix != null) {
+      qrcode = qrcode.substring(qrBuilder.prefix.length());
+    }
+    final String encDataPlain = new String(aesDecryptCFB(qrBuilder.rootKey, decodeBase64(qrcode), qrBuilder.iv));
+    if (qrBuilder.debug) {
+      logger.info("Decode data : <" + encDataPlain + ">");
+    }
+    String[] fields = encDataPlain.split(DELIMITER);
+    if (fields.length < 3) {
+      throw new RuntimeException("qrcode plain text format error!");
+    }
+    String uid = fields[0];
+    String[] scopes = fields[1].split(SCOPE_DELIMITER);
+    String totp = fields[2];
+
+    if (!verifyTotp(totp, qrBuilder.seed, TOTP_OFFSET)) {
+      throw new RuntimeException("qrcode is invalid!"); //TOTP校验
+    }
+
+    if (!checkScope(Arrays.asList(scopes))) {
+      throw new RuntimeException("qrcode is not supported!"); //应用场景校验
+    }
+
+    return uid;
+  }
+
+  public String tac(String uid, Integer amount, String transdate, String transtime) {
+    if (null == qrBuilder.appid) {
+      throw new RuntimeException("appid is null");
+    }
+    if (null == qrBuilder.sKey) {
+      throw new RuntimeException("sKey is null");
+    }
+    final String encdata = uid + qrBuilder.appid + amount + transdate + transtime + "{" + encodeHex(qrBuilder.sKey) + "}";
+    return encodeHex(sha256(encdata.getBytes())).toUpperCase();
+  }
+
+}
diff --git a/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/StringJoin.java b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/StringJoin.java
new file mode 100644
index 0000000..fdc5ca8
--- /dev/null
+++ b/payapi/src/main/java/com/supwisdom/dlpay/busQRcode/StringJoin.java
@@ -0,0 +1,30 @@
+package com.supwisdom.dlpay.busQRcode;
+
+public class StringJoin {
+  private String delimiter;
+  private StringBuilder builder;
+
+  public StringJoin(String delimiter) {
+    if(delimiter == null) {
+      throw new RuntimeException("Delimiter must not be null");
+    }
+    this.delimiter = delimiter;
+    builder = new StringBuilder();
+  }
+
+  public StringJoin add(String field) {
+    if(field == null) {
+      throw new RuntimeException("added value must not be null");
+    }
+    if(builder.length() > 0) {
+      builder.append(this.delimiter);
+    }
+    builder.append(field);
+    return this;
+  }
+
+  @Override
+  public String toString() {
+    return builder.toString();
+  }
+}