> ## Documentation Index
> Fetch the complete documentation index at: https://docs-payment-merchant.keysecure.io/llms.txt
> Use this file to discover all available pages before exploring further.

# 代码工具类

## 10.1 签名工具类（SignUtils）

平台回调商户时，请求头携带 `Timestamp`、`Signature`、`Sign-Version`（V1）、`Timezone`（UTC+8）。

签名对 **`data` 对象**（JSON 字符串）计算，**不含** 顶层 `id`、`action` 字段。

```java theme={null}
/**
 * 签名工具类 
 * 算法：HMAC-SHA256，输出小写十六进制
 */
public class SignUtils {

    public static String sign(String appId, String timestamp, String body, String secretKey) throws Exception {
        return sign(appId, timestamp, buildBodyMap(body), secretKey);
    }

    public static String sign(String appId, String timestamp, Map<String, String> body, String secretKey) throws Exception {
        TreeMap<String, String> params = new TreeMap<>();
        params.put("appId", appId);
        params.put("timestamp", timestamp);
        if (body != null && !body.isEmpty()) {
            params.putAll(body);
        }
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, String> entry : params.entrySet()) {
            if (entry.getValue() == null) continue;
            if (sb.length() > 0) sb.append("&");
            sb.append(entry.getKey()).append("=").append(entry.getValue());
        }
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        return bytesToHex(mac.doFinal(sb.toString().getBytes(StandardCharsets.UTF_8)));
    }

    public static Map<String, String> buildBodyMap(String body) {
        Map<String, String> bodyMap = new TreeMap<>();
        if (StringUtils.isBlank(body)) return bodyMap;
        JSONObject json = JSONObject.parseObject(body);
        for (Map.Entry<String, Object> e : json.entrySet()) {
            if (e.getValue() == null) continue;
            Object v = e.getValue();
            bodyMap.put(e.getKey(), v instanceof String ? (String) v : JSONObject.toJSONString(v));
        }
        return bodyMap;
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }
}
```

<Note>
  * 参与签名的公共参数仅 **`appId`**、**`timestamp`** 及 **data 内各字段**；顶层 `id`、`action` **不参与**签名。
  * `buildBodyMap` 为 **public** 方法，商户验签时可复用相同逻辑。
</Note>

## 10.2 商户敏感数据加解密工具类

### 适用场景

| 接口                                 | 方向       | 说明                           |
| ---------------------------------- | -------- | ---------------------------- |
| `GET /card/{card_no}/private/info` | 服务端 → 商户 | 响应 `data` 为加密字符串，商户解密后得 JSON |
| `POST /card/active`                | 商户 → 服务端 | 请求 `cvv` 须为加密后的 Base64 密文    |
| `POST /card/pin/set`               | 商户 → 服务端 | 请求 `pin` 须为加密后的 Base64 密文    |

### 算法说明

| 项     | 值                                                                              |
| ----- | ------------------------------------------------------------------------------ |
| 算法    | AES-256                                                                        |
| 模式/填充 | **CBC / PKCS5Padding**（每次加密随机 16 字节 IV）                                        |
| 密钥派生  | `SHA-256(apiSecret UTF-8)` → 32 字节 AES 密钥（apiSecret 为变长字符串，**不可**直接作为 AES Key） |
| 密文格式  | `Base64( IV[16字节] ‖ AES-CBC密文 )`                                               |
| 字符集   | UTF-8（明文）                                                                      |

<Note>
  设置 PIN 明文须 **6 位数字**且不可含三位及以上相同或连续数字，否则返回 **4016**。

  实体卡激活明文 CVV 须 **3 位纯数字**；解密失败返回 **4015** / **4019**。
</Note>

### 工具类代码

```java theme={null}
/**
 * 敏感字段加解密：PIN / CVV / 卡隐私 JSON
 */
public class MerchantEncryptUtil {

    private static final String KEY_ALGORITHM = "AES";

    private static final String CIPHER_ALGORITHM_CBC = "AES/CBC/PKCS5Padding";

    private static final int IV_LENGTH = 16;

    private static final SecureRandom SECURE_RANDOM = new SecureRandom();

    public static String encrypt(String data, String secretKey) {
        if (StringUtils.isAnyBlank(data, secretKey)) {
            log.error("MerchantEncryptUtil encrypt param is blank");
            return null;
        }
        try {
            byte[] iv = new byte[IV_LENGTH];
            SECURE_RANDOM.nextBytes(iv);
            IvParameterSpec ivSpec = new IvParameterSpec(iv);

            Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM_CBC);
            cipher.init(Cipher.ENCRYPT_MODE, buildAesKey(secretKey), ivSpec);
            byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));

            byte[] combined = new byte[IV_LENGTH + encrypted.length];
            System.arraycopy(iv, 0, combined, 0, IV_LENGTH);
            System.arraycopy(encrypted, 0, combined, IV_LENGTH, encrypted.length);

            return Base64.encodeBase64String(combined);
        } catch (Exception e) {
            return null;
        }
    }

    public static String decrypt(String encryptedData, String secretKey) {
        if (StringUtils.isAnyBlank(encryptedData, secretKey)) {
            log.error("MerchantEncryptUtil decrypt param is blank");
            return null;
        }
        byte[] combined = Base64.decodeBase64(encryptedData);

        try {
            byte[] iv = Arrays.copyOfRange(combined, 0, IV_LENGTH);
            byte[] cipherBytes = Arrays.copyOfRange(combined, IV_LENGTH, combined.length);
            IvParameterSpec ivSpec = new IvParameterSpec(iv);

            Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM_CBC);
            cipher.init(Cipher.DECRYPT_MODE, buildAesKey(secretKey), ivSpec);
            byte[] decrypted = cipher.doFinal(cipherBytes);
            return new String(decrypted, StandardCharsets.UTF_8);
        } catch (Exception cbcEx) {
            log.warn("MerchantEncryptUtil CBC decrypt failed, fallback to ECB (legacy data): {}", cbcEx.getMessage());
        }
        return null;
    }

    private static SecretKeySpec buildAesKey(String secretKey) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] keyBytes = digest.digest(secretKey.getBytes(StandardCharsets.UTF_8));
            return new SecretKeySpec(keyBytes, KEY_ALGORITHM);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-256 not available", e);
        }
    }
}
```

### 调用示例

**1）实体卡激活 — 商户加密 CVV 后上送**

```java theme={null}
String apiSecret = "your_api_secret_plaintext";
String plainCvv = "123";
String encryptedCvv = MerchantEncryptUtil.encrypt(plainCvv, apiSecret);

// 请求体
// {
//   "card_no": "...",
//   "cardholder_no": "...",
//   "cvv": "<encryptedCvv>",
//   "card_last_no": "1234"
// }
```

**2）设置实体卡 PIN — 商户加密后上送**

```java theme={null}
String apiSecret = "your_api_secret_plaintext";
String plainPin = "123456";
String encryptedPin = MerchantEncryptUtil.encrypt(plainPin, apiSecret);

// 请求体
// { "card_no": "...", "cardholder_no": "...", "pin": "<encryptedPin>" }
```

**3）获取卡隐私 JSON — 商户解密响应 data**

```java theme={null}
String apiSecret = "your_api_secret_plaintext";
String encryptedData = response.getData(); // HTTP 响应中的 data 字段
String json = MerchantEncryptUtil.decrypt(encryptedData, apiSecret);
// json 为卡隐私信息 JSON 字符串
```

### 相关错误码

| code | 中文描述                 |
| ---- | -------------------- |
| 1013 | 未开通 PCI 敏感卡展示能力      |
| 4015 | PIN 码解密失败，请检查加密方式与密钥 |
| 4018 | 未找到可用的 API 凭证        |
| 4019 | CVV 解密失败，请检查加密方式与密钥  |
