Administrator
6 days ago 438e8de2bf8023cc9be1a8cf481fdbeb0d16fc98
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
package cc.mrbird.febs.mall.controller.dependentStation;
 
import cc.mrbird.febs.mall.entity.DataDictionaryCustom;
import cc.mrbird.febs.mall.mapper.DataDictionaryCustomMapper;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.TypeReference;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
import javax.annotation.Resource;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.HttpServletRequest;
import java.io.BufferedReader;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
 
/**
 * Tokenview 地址监控 Webhook 接收端点
 * 接收 Tokenview 推送的链上交易通知,完成充值匹配
 *
 * @author auto-generated
 */
@Slf4j
@RestController
@RequestMapping("/api/tokenview")
public class TokenviewWebhookController {
 
    @Resource
    private TokenviewWebhookService tokenviewWebhookService;
 
    @Resource
    private DataDictionaryCustomMapper dataDictionaryCustomMapper;
 
    /** 默认 Webhook 签名密钥(数据库未配置时回退使用) */
    private static final String DEFAULT_WEBHOOK_SECRET = "dd12521274e434115df5c4277755839766349007fb57936d9d5be0a7a4f0e42f";
 
    /** Webhook 配置字典 type */
    private static final String TOKENVIEW_DICT_TYPE = "TOKENVIEW_CONFIG";
    private static final String TOKENVIEW_DICT_CODE = "WEBHOOK_SECRET";
 
    /**
     * 获取 Webhook 签名密钥(HMAC-SHA256)
     * 优先从数据库 data_dictionary_custom 读取,未配置则回退默认值
     */
    private String getWebhookSecret() {
        DataDictionaryCustom dict = dataDictionaryCustomMapper.selectDicDataByTypeAndCode(
                TOKENVIEW_DICT_TYPE, TOKENVIEW_DICT_CODE);
        if (dict != null && StrUtil.isNotBlank(dict.getValue())) {
            return dict.getValue();
        }
        return DEFAULT_WEBHOOK_SECRET;
    }
 
    /**
     * 接收 Tokenview 地址监控推送
     * <p>
     * Tokenview 在监测到地址有交易时,会 POST JSON 到此端点。
     * 需要在 Tokenview 后台配置此 URL 和 Secret Key。
     * <p>
     * 配置步骤:
     * 1. 登录 Tokenview 开发者后台
     * 2. 进入「地址监控」模块
     * 3. 添加要监控的 TRC20 地址
     * 4. 配置 Webhook URL: https://your-domain/api/tokenview/webhook
     * 5. 设置 Secret Key(与此处 WEBHOOK_SECRET 一致)
     */
    @PostMapping("/webhook")
    public Map<String, Object> receiveWebhook(HttpServletRequest request) {
        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("msg", "ok");
 
        try {
            // 1. 读取原始请求体
            String rawBody = readRequestBody(request);
            if (StrUtil.isBlank(rawBody)) {
                result.put("code", 1);
                result.put("msg", "请求体为空");
                return result;
            }
 
            // 2. 验证签名
            String signature = request.getHeader("X-Tokenview-Signature");
            if (StrUtil.isNotBlank(signature)) {
                if (!verifySignature(rawBody, signature)) {
                    log.error("Webhook 签名验证失败: signature={}", signature);
                    result.put("code", 2);
                    result.put("msg", "签名验证失败");
                    return result;
                }
            } else {
                // 也尝试兼容其他常见签名头
                signature = request.getHeader("Tokenview-Signature");
                if (StrUtil.isNotBlank(signature) && !verifySignature(rawBody, signature)) {
                    log.error("Webhook 签名验证失败: signature={}", signature);
                    result.put("code", 2);
                    result.put("msg", "签名验证失败");
                    return result;
                }
            }
 
            log.info("收到 Tokenview Webhook 推送: {}", rawBody);
 
            // 3. 解析 JSON
            Map<String, Object> payload = JSON.parseObject(
                    rawBody,
                    new TypeReference<Map<String, Object>>() {}
            );
            if (payload == null || payload.isEmpty()) {
                result.put("code", 3);
                result.put("msg", "JSON 解析失败");
                return result;
            }
 
            // 4. 处理充值匹配
            String processResult = tokenviewWebhookService.processWebhookTransaction(payload);
            log.info("Webhook 处理结果: {}", processResult);
 
            if (processResult != null && processResult.startsWith("SUCCESS")) {
                result.put("msg", processResult);
            } else if (processResult != null && !processResult.startsWith("SUCCESS")) {
                // 非致命错误(如金额未匹配、哈希已使用等),仍返回 200,避免 Tokenview 重复推送
                result.put("msg", processResult);
            }
 
        } catch (Exception e) {
            log.error("Webhook 处理异常", e);
            result.put("code", 500);
            result.put("msg", "处理异常: " + e.getMessage());
        }
 
        return result;
    }
 
    /**
     * 读取请求体原始字符串
     */
    private String readRequestBody(HttpServletRequest request) {
        try {
            StringBuilder sb = new StringBuilder();
            BufferedReader reader = request.getReader();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line);
            }
            return sb.toString();
        } catch (Exception e) {
            log.error("读取请求体失败", e);
            return null;
        }
    }
 
    /**
     * 验证 HMAC-SHA256 签名
     *
     * @param payload   原始请求体
     * @param signature 请求头中的签名值
     * @return true-验证通过, false-验证失败
     */
    private boolean verifySignature(String payload, String signature) {
        try {
            String secret = getWebhookSecret();
            Mac sha256Hmac = Mac.getInstance("HmacSHA256");
            SecretKeySpec secretKey = new SecretKeySpec(
                    secret.getBytes(StandardCharsets.UTF_8),
                    "HmacSHA256"
            );
            sha256Hmac.init(secretKey);
            byte[] signedBytes = sha256Hmac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
            String expectedSignature = bytesToHex(signedBytes);
 
            // 同时尝试 Base64 编码的签名(兼容不同 Tokenview 版本)
            String expectedBase64 = Base64.getEncoder().encodeToString(signedBytes);
 
            boolean hexMatch = expectedSignature.equalsIgnoreCase(signature);
            boolean base64Match = expectedBase64.equals(signature);
 
            if (!hexMatch && !base64Match) {
                log.warn("签名不匹配: expected_hex={}, expected_base64={}, actual={}",
                        expectedSignature, expectedBase64, signature);
                return false;
            }
            return true;
        } catch (Exception e) {
            log.error("签名验证计算异常", e);
            return false;
        }
    }
 
    /**
     * 字节数组转十六进制字符串
     */
    private String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }
}