SpringBoot implementa un pequeño programa de pago de WeChat (super detallado)

entorno de desarrollo

  • java1.8
  • experto 3.3.9
  • springboot 2.1.3.LIBERAR

Paso 1: Abrir pago JSAPI

Paso 2: Acoplamiento de la tecnología SpringBoot

Primer vistazo al proceso de pago de WeChat

La principal interacción entre el sistema comercial y el sistema de pago WeChat:

1. Llame a la interfaz de inicio de sesión en el applet para obtener el ID abierto del usuario. Para la API, consulte la API pública 【API de inicio de sesión de programa pequeño

2. El servidor comercial llama al pago para realizar un pedido de forma unificada. Para la API, consulte la API pública 【API de pedidos unificados

3. Llamadas del servidor comercial para volver a firmar, api ver api pública [ re-firmar ]

4. El servidor del comerciante recibe una notificación de pago, api ve la api pública 【API de notificación de resultados de pago

5. Resultado del pago de la consulta del servidor del comerciante, api ver api pública 【API de orden de consulta

Tenga en cuenta que hay dos firmas arriba  

1. Clase de archivo de configuración

1 2 
 3 public final class WxConfig { 
 4 public final static String appId="wxe86f60xxxxxxx"; // applet appid 
 5 public final static String mchId="15365xxxxx";// ID del comerciante 
 6 public final static String key="Ucsdfl782167bjslNC3skJD12986" // La clave acordada con el pago de WeChat 
 7 public final static String notificarPath="/admin/wxnotify"; // Dirección de devolución de llamada 
 8 public final static String payUrl="https://api.mch.weixin.qq.com/pay /unifiedorder" ; // Dirección de orden unificada 
 9 public final static String tradeType="JSAPI"; // Método de pago 
10 
11 }

2. Clase de herramienta Wechat, orden unificado, firma y generación de cadenas aleatorias. .

  4 importar lombok.extern.slf4j.Slf4j; 
  5 importar org.apache.http.HttpEntity; 
  6 importar org.apache.http.HttpResponse; 
  7 importar org.apache.http.client.HttpClient; 
  8 importar org.apache.http.client.config.RequestConfig; 
  9 importar org.apache.http.client.methods.HttpPost; 
 10 importar org.apache.http.config.RegistryBuilder; 
 11 import org.apache.http.conn.socket.ConnectionSocketFactory; 
 12 import org.apache.http.conn.socket.PlainConnectionSocketFactory; 
 13 importar org.apache.http.conn.ssl.SSLConnectionSocketFactory; 
 14 importar org.apache.http.entity.StringEntity; 
 15 import org.apache.http.impl.client.HttpClientBuilder;
 16 importar org.apache.http.impl.conn.BasicHttpClientConnectionManager; 
 17 importar org.apache.http.util.EntityUtils;
 18 importar org.slf4j.Logger; 
 19 importar org.slf4j.LoggerFactory; 
 20 importar org.w3c.dom.Documento; 
 21 importar org.w3c.dom.Element; 
 22 importar org.w3c.dom.Node; 
 23 importar org.w3c.dom.NodeList; 
 24 
 25 importar javax.crypto.Mac; 
 26 importar javax.crypto.spec.SecretKeySpec; 
 27 importar javax.xml.XMLConstants; 
 28 importar javax.xml.parsers.DocumentBuilder; 
 29 import javax.xml.parsers.DocumentBuilderFactory; 
 30 import javax.xml.parsers.ParserConfigurationException; 
 31 importar javax.xml.transform.OutputKeys; 
 34 importar javax.xml.transform.dom.DOMSource;
 32 importar javax.xml.transform.Transformer;
 33 importar javax.xml.transform.TransformerFactory; 
 35 importar javax.xml.transform.stream.StreamResult; 
 36 importar java.io.ByteArrayInputStream; 
 37 importar java.io.InputStream; 
 38 importar java.io.StringWriter; 
 39 import java.security.MessageDigest; 
 40 import java.security.SecureRandom; 
 41 import java.time.Instant; 
 42 import java.util.*; 
 43 
 44 @Slf4j 
 45 public class WxUtil { 
 46 private static final String WXPAYSDK_VERSION = "WXPaySDK/3.0.9"; 
 47 Private static final String USER_AGENT = WXPAYSDK_VERSION + 
 48 " (" + System.getProperty("os.arch") + " " + System.getProperty("os.name") + " " + System.
 49 ") Java/" + System.getProperty("java.version") + " HttpClient/" + HttpClient.class.getPackage().getImplementationVersion(); 51 privado estático final 
 50
 String SYMBOLS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; 
 52 privado estático final Random RANDOM = new SecureRandom(); 
 53 // Interfaz de orden unificado 
 54 public static Map<String, String> unifiedOrder(Map<String, String> reqData) throws Exception { 
 55 // El formato del mapa para el método xml está por debajo de 
 56 String reqBody = mapToXml(reqData); 
 57 // El contenido del método de solicitud para iniciar una orden unificada está por debajo de 
 58 String responseBody = requestOnce(WxConfig.payUrl, reqBody); 
 59 // Convierte el resultado obtenido de formato xml a formato de mapa El contenido del método está en debajo 
 60 Map<String,String> response= processResponseXml(responseBody); 
 61 // Obtener prepayId 
 62 String prepayId = response.get("prepay_id");
 63 // ¿Por qué el parámetro de ensamblaje package_str es así? Debido a que la firma secundaria WeChat estipula dicho formato 
 64 String package_str = "prepay_id="+prepayId; 
 65 Map<String,String> payParameters = new HashMap<>(); 
 66 long epochSecond = Instant.now().getEpochSecond(); 
 67 payParameters.put("appId",WxConfig.appId); 
 68 payParameters.put("nonceStr", WxUtil.generateNonceStr()); 
 69 payParameters.put("paquete", package_str); 
 70 payParameters.put("signType" , SignType.MD5.name()); 
 71 payParameters.put("timeStamp", String.valueOf(epochSecond)); 
 72 // firma secundaria
 73 payParameters.put("paySign", WxUtil.generateSignature(payParameters, WxConfig.key, SignType.MD5)); 
 75 return payParameters; 
 76 } 
 77 
 78 
 79 /** 
 80 * Genera una firma. Tenga en cuenta que si el campo sign_type es incluido, debe ser coherente con el parámetro signType. 
 81 * 
 82 * @param data datos que se firmarán 
 83 * @param key API key 
 84 * @param signType método de firma 
 85 * @return signature 
 86 */ 
 87 public static String generateSignature(final Map<String, String> data, String key , SignType signType) arroja una excepción { 
 88 Set<String> keySet = data.keySet(); 
 89 String[] keyArray = keySet.toArray(new String[keySet.size()]);
 90 Arrays.sort(keyArray); 
 91 StringBuilder sb = nuevo StringBuilder(); 
 92 for (String k : keyArray) { 
 93 if (k.equals("sign")) { 
 94 continue; 
 95 } 
 96 if (data.get(k).trim().length() > 0) // 参数值为空,则不参与签名 
104 return HMACSHA256(sb.
 97 sb.append(k).append("=").append(data.get(k).trim()).append("&"); 
 98 } 
 99 sb.append("clave=").append(clave); 
100 if (SignType.MD5.equals(signType)) { 
101 return MD5(sb.toString()).toUpperCase(); 
102 } 
103 else if (SignType.HMACSHA256.equals(signType)) { 
105 } 
106 else { 
107 throw new Exception(String.format("Invalid sign_type: %s", signType)); 
108 } 
109 } 
110 
111 /** 
112 * 生成 MD5 
113 * 
114 * @param data 待处理数据
115 * @return MD5结果
116 */ 
117 private static String MD5(String data) throws Exception { 
118 MessageDigest md = MessageDigest.getInstance("MD5"); 
matriz de 119 bytes[] = md.digest(data.getBytes("UTF-8")); 
120 StringBuilder sb = nuevo StringBuilder(); 
121 para (elemento de byte: matriz) { 
122 sb.append(Integer.toHexString((elemento & 0xFF) | 0x100).subcadena(1, 3)); 
123 } 
124 return sb.toString().toUpperCase();
127 public static String generarNonceStr() {  
128 char[] nonceChars = new char[32]; 
129 for (int index = 0; index < nonceChars.length; ++index) {
130 nonceChars[índice] = SÍMBOLOS.charAt(ALEATORIO.nextInt(SÍMBOLOS.longitud())); 
131 } 
132 devuelve una nueva cadena (nonceChars); 
133 } 
134 
135 public static String mapToXml(Map<String, String> data) throws Exception { 
136 Documento documento = nuevoDocumento(); 
137 Raíz del elemento = document.createElement("xml"); 
138 documento.appendChild(raíz); 
139 for (Clave de cadena: data.keySet()) { 
140 Valor de cadena = data.get(key); 
141 si (valor == nulo) { 
142 valor = ""; 
143 } 
144 valor = valor.trim();
145 Elemento archivado = documento.createElement(clave); 
146 archivado.appendChild(document.createTextNode(valor)); 
147 root.appendChild(archivado); 
148 } 
149 TransformerFactory tf = TransformerFactory.newInstance(); 
150 Transformador transformador = tf.newTransformer(); 
151 fuente DOMSource = new DOMSource(documento); 
152 transformador.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); 
153 transformador.setOutputProperty(OutputKeys.INDENT, "sí"); 
154 StringWriter escritor = new StringWriter(); 
155 resultado de StreamResult = new StreamResult(escritor);
156 transformador.transformar(fuente, resultado);
157 Cadena de salida = escritor.getBuffer().toString(); //.reemplazarTodo("\n|\r", ""); 
158 prueba { 
159 escritor.cerrar(); 
160 } 
161 catch (Excepción ex) { 
162 } 
163 return salida; 
164 } 
165 
166 // 判断签名是否有效
167 private static Map<String, String> processResponseXml(String xmlStr) throws Exception { 
168 String RETURN_CODE = "return_code"; 
169 Cadena código_retorno; 
170 Mapa<Cadena, Cadena> respData = xmlToMap(xmlStr); 
171 if (respData.containsKey(RETURN_CODE)) { 
172 return_code = respData.get(RETURN_CODE); 
173 }
175 else { 
176 throw new Exception(String.format("Sin `return_code` en XML: %s", xmlStr)); 
177 } 
178 
179 if (return_code.equals("FAIL")) { 
180 return respData; 
181 } 
182 else if (return_code.equals("SUCCESS")) { 
183 if (isResponseSignatureValid(respData)) { 
184 return respData; 
185 } 
186 else { 
187 throw new Exception(String.format("Valor de signo no válido en XML: %s", xmlStr)); 
188 } 
189 } 
190 más {
191 throw new Exception(String.format("return_code value %s is invalid in XML: %s", return_code, xmlStr)); 
192 } 
193 } 
194 // 判断签名
195 booleano estático privado isResponseSignatureValid(Map<String, String> data) throws Exception { 
196 String signKeyword = "sign"; 
197 if (!data.containsKey(signKeyword) ) { 
198 return false; 
199 } 
200 String sign = data.get(signKeyword); 
201 return generateSignature(data, WxConfig.key, SignType.MD5).equals(sign); 
202 } 
203 
204 // 发起一次请求
205 privado estático String requestOnce(String payUrl, String data) arroja una excepción {
206 BasicHttpClientConnectionManager connManager;  
219 .build();
207 connManager = new BasicHttpClientConnectionManager( 
208 RegistryBuilder.<ConnectionSocketFactory>create() 
209 .register("http", PlainConnectionSocketFactory.getSocketFactory()) 
210 .register("https", SSLConnectionSocketFactory.getSocketFactory()) 
211 .build(), 
212 nulo, 
213 nulo, 
214 nulo 
215); 
216 
217 HttpClient httpClient = HttpClientBuilder.create() 
218 ​​.setConnectionManager(connManager)
221
220 HttpPost httpPost = nuevo HttpPost(payUrl); 
222 RequestConfig requestConfig = RequestConfig.custom().setSocketTimeout(8000).setConnectTimeout(6000).build(); 
223 httpPost.setConfig(solicitudConfig); 
224 
225 StringEntity postEntity = new StringEntity(datos, "UTF-8"); 
226 httpPost.addHeader("Tipo de contenido", "texto/xml"); 
227 httpPost.addHeader("User-Agent", USER_AGENT + " " + WxConfig.mchId); 
228 httpPost.setEntity(postEntity); 
229 
230 HttpResponse httpResponse = httpClient.execute(httpPost); 
231 HttpEntity httpEntity = httpResponse.getEntity(); 
232 devuelve EntityUtils.toString(httpEntity, "UTF-8");
238 cadena estática privada HMACSHA256 (datos de cadena, clave de cadena) arroja una excepción { 
239 Mac sha256_HMAC = Mac.getInstance ("HmacSHA256"); 
240 SecretKeySpec secret_key = new SecretKeySpec(key.getBytes("UTF-8"), "HmacSHA256"); 
241 sha256_HMAC.init(clave_secreta); 
matriz de 242 bytes[] = sha256_HMAC.doFinal(data.getBytes("UTF-8")); 
243 StringBuilder sb = nuevo StringBuilder(); 
244 para (elemento de byte: matriz) { 
245 sb.append(Integer.toHexString((elemento & 0xFF) | 0x100).subcadena(1, 3)); 
246 } 
247 return sb.toString().toUpperCase(); 
249 
251 DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); 
252 documentBuilderFactory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); 
253 documentBuilderFactory.setFeature("http://xml.org/sax/features/external-general-entities", false); 
254 documentBuilderFactory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); 
255 documentBuilderFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); 
256 documentBuilderFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
257 documentBuilderFactory.setXIncludeAware(falso); 
258 documentBuilderFactory.setExpandEntityReferences(false); 
259 
260 return documentBuilderFactory.newDocumentBuilder(); 
261 } 
262 
263 documento estático privado newDocument() throws ParserConfigurationException { 
264 return newDocumentBuilder().newDocument(); 
265 } 
267
271 DocumentBuilder documentBuilder = newDocumentBuilder();
267 
268 public static Map<String, String> xmlToMap(String strXML) throws Exception { 
269 try { 
270 Map<String, String> data = new HashMap<String, String>(); 
272 Flujo InputStream = new ByteArrayInputStream(strXML.getBytes("UTF-8")); 
273 org.w3c.dom.Document doc = documentBuilder.parse(flujo); 
274 doc.getDocumentElement().normalize(); 
275 NodeList nodeList = doc.getDocumentElement().getChildNodes(); 
276 for (int idx = 0; idx < nodeList.getLength(); ++idx) { 
277 Nodo nodo = nodeList.item(idx); 
278 if (nodo.getNodeType() == Nodo.ELEMENT_NODE) ​​{ 
279 org.w3c.dom.Element elemento = (org.w3c.dom.Element) nodo; 
280 data.put(element.getNodeName(), element.getTextContent()); 
281 } 
282 } 
283 prueba { 
284 stream.close(); 
285 } catch (excepción ex) {
286 // no hacer nada 
287 } 
288 devolver datos; 
289 } catch (excepción ex) { 
290 getLogger().warn("XML no válido, no se puede convertir a mapa. Mensaje de error: {}. Contenido XML: {}", ex .getMessage(), strXML); 
291 throw ex; 
292 } 
293 
294 } 
295 /**  
296 * 日志
297 * @return
298 */ 
299 registrador estático privado getLogger() { 
300 registrador registrador = LoggerFactory.getLogger("wxpay java sdk"); 
301 devolver registrador ; 
302 } 
303 
304 
305 
306 /** 
307 * Determinar si la firma es correcta 
308 * 
309 * @param xmlStr Datos en formato XML 
310 * @param key API key 
311 * @return si la firma es correcta 
312 * @throws Exception 
313 */ 
314 public static boolean isSignatureValid(String xmlStr, String key) throws Exception { 
315 Map<String, String> data = xmlToMap(xmlStr ); 
316 if (!data. containsKey("sign") ) { 
317 return false; 
318 }
319 Cadena signo = data.get("signo"); 
320 return generateSignature(datos, clave,SignType.MD5).equals(signo); 
321 } 
322 
323 
324 
325 
326 
327 }            

3. El applet inicia una solicitud para ensamblar los parámetros necesarios para iniciar una orden unificada

1 @PostMapping("/recharge/wx") 
 2 public Map recharge(HttpServletRequest request, @RequestParam(value = "vipType",required = true) VipType vipType) throws Exception { 
 3 // Este caso se basa en Cambiar la situación real según sus propias necesidades 
 4 Integer loginDealerId = MySecurityUtil.getLoginDealerId(); 
 5 // Obtenga la dirección IP necesaria para iniciar un pedido unificado 
 6 String ipAddress = HttpUtil.getIpAddress(request); 
 7 // Genere un pedido prepago y guárdelo en el base de datos La devolución de llamada es exitosa y el estado del pedido se modifica 
 8 PrepaidOrder prepaidOrder = payService.recharge(loginDealerId, vipType, ipAddress); 
 9 // Ensamblar el mapa de datos requerido para la colocación unificada de pedidos 
10 Map<String, String> stringStringMap = prepaidOrder.toWxPayParameters (); 
11 // invocar pago unificado
12 Map<String, String> payParameters =WxUtil.unifiedOrder(stringStringMap); 
13 parámetros de pago de retorno; 
14 }

Generar código de pedido prepago (generado de acuerdo con las necesidades reales, aquí están mis necesidades, solo como referencia)

27 @Service("WXPayService") 
28 @Slf4j 
29 public class PayServiceImpl implements PayService { 
30 
33 @Resource 
34 PrepaidOrderDao prepaidOrderDao; 
35 
36 @Recurso 
37 VipDao vipDao; 
38 
39 @Resource 
40 DealerDao dealerDao; 
41 
42 @Resource 
43 ApplicationContext applicationContext; 
44 @Override 
45 @Transactional 
46 public PrepaidOrder recarga(Integer dealerId, VipType vipType, String userIp) { 
47 Dealer dealer = dealerDao.getDealerById(dealerId); 
48 SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
49 Cadena nuevaFecha = sdf.format(nueva Fecha());
50 Aleatorio aleatorio = nuevo Aleatorio(); 
51 Cadena orderNumber = newDate + random.nextInt(1000000); 
52 Cantidad BigDecimal = nulo; 
53 // 如果不是生产环境 付一分钱
54 if (!applicationContext.getEnvironment().getActiveProfiles()[0].contains("prod")){ 
55 cantidad = BigDecimal.valueOf(0.01); 
56 }else if (vipType.equals(VipType.YEAR)){ 
57 cantidad= BigDecimal.valueOf(999); 
58 }else { 
59 cantidad = BigDecimal.valueOf(365); 
60 } 
61 PrepaidOrder prepaidOrder = new PrepaidOrder(); 
62 prepaidOrder.setDealerId(dealerId);
63 prepaidOrder.setOpenId(dealer.getOpenId()); // Este es el openid requerido por WeChat  
64 prepaidOrder. setVipType(vipType);
65 prepaidOrder.setUserIp(userIp); // Este es el parámetro userIp requerido por WeChat 
66 prepaidOrder.setOrderStatus(OrderStatus.EN CURSO); 
67 prepaidOrder. setAmount(amount); // Este es el parámetro total_fee requerido por WeChat 
68 prepaidOrder.setOrderNumber(orderNumber); // Este es el parámetro out_trade_no requerido por WeChat 
69 // Agregar pedido prepago 
70 prepaidOrderDao.addPrepaidOrder(prepaidOrder); 
71 devolver pedido prepago;/ / devolver pedido prepago
72 } 73 }

Hacer la encapsulación de parámetros final en la clase de entidad

1 @Data 
 2 public class PrepaidOrder extends BaseModel { 
 3 private String orderNumber; 
 4 private Integer dealerId; 
 5 número privado entero versión; 
 6 monto BigDecimal privado; 
 7 privado OrderStatus orderStatus=OrderStatus.EN CURSO; 
 8 privado LocalDateTime SuccessTime; 
 9 IP de usuario de cadena privada; 
10 cadena privada openId; 
11 privado VipType vipType; 
12 
13 public Map<String, String> toWxPayParameters() throws Exception { 
14 Map map = new HashMap(); 
15 mapa.put("cuerpo",obtenerCuerpo()); // 商品名字
16 map.put("appid", WxConfig.appId); // 小程序appid
17 map.put("mch_id", WxConfig.mchId); // ID de usuario
18 map.put("nonce_str", WxUtil.generateNonceStr()); // cadena aleatoria 
19 map.put("notify_url", AppConst.host+WxConfig.notifyPath); // dirección de devolución de llamada 
20 map.put("openid " ,this.openId); // openid del usuario que inicia el pago de WeChat 
21 map.put("out_trade_no",this.orderNumber); // número de pedido 
22 map.put("spbill_create_ip",this.userIp); // La dirección IP del usuario que inicia el pago de WeChat 
23 map.put("total_fee", parseAmount()); // cantidad (puntos de unidad) 
24 map.put("trade_type",WxConfig.tradeType); // tipo de pago 
25 // La firma de datos también es la primera firma 
26 map.put("sign", WxUtil.generateSignature(map, WxConfig.key, SignType.MD5 )); 
27 return map;
28 } 
31 if (vipType.equals(VipType.YEAR)){ 
29
30 public String getBody(){ 
32 return "年度会员"; 
33 }else { 
34 return "季度会员"; 
35 } 
36 } 
37 
38 public String parseAmount(){ 
39 BigDecimal multiplicar = cantidad.multiplicar(BigDecimal.valueOf(100)); 
40 resultado BigDecimal = multiplicar; 
41 if (multiply.compareTo(BigDecimal.valueOf(1))==0){ 
42 resultado = BigDecimal.valueOf(1); 
43 } 
44 return resultado.toString(); 
45 } 
46 
47 @Override 
48 public String toString() { 
49 return "PrepaidOrder{" + 
50 "orderNumber='" + orderNumber + '\'' + 
51 ", dealerId=" + dealerId + 
52 ", versionNum=" + versionNum + 
53 ", cantidad=" + cantidad + 
54 ", orderStatus=" + orderStatus + 
55 ", SuccessTime=" + SuccessTime + 
56 ", userIp='" + userIp + '\'' + 
57 ", openId='" + openId + '\'' + 
58 ", vipType=" + vipType + 
59 ' }'; 
60 } 
61 }

4. La clase de enumeración de tipo de firma public enum SignType { MD5, HMACSHA256 } 

5. Obtener clase de herramienta de IP de usuario

1 public static String getIpAddress(HttpServletRequest request) { 
 2 String ip = request.getHeader("x-forwarded-for"); 
 3 if (ip == null || ip.length() == 0 || "desconocido".equalsIgnoreCase(ip)) { 
 4 ip = request.getHeader("Proxy-Client-IP"); 
 5 } 
 6 if (ip == null || ip.length() == 0 || "desconocido".equalsIgnoreCase(ip)) { 
 7 ip = request.getHeader("WL-Proxy-Client-IP"); 
 8 } 
 9 if (ip == null || ip.length() == 0 || "desconocido".equalsIgnoreCase(ip)) { 
10 ip = request.getHeader("HTTP_CLIENT_IP"); 
11 } 
12 if (ip == nulo || ip.longitud() == 0 || " 
13 ip = solicitud.getHeader("
15 if (ip == null || ip.length() == 0 || "desconocido".equalsIgnoreCase(ip)) { 
16 ip = request.getRemoteAddr(); 
17 } 
18 devolver ip; 
19 }

El mini programa inicia el pago de WeChat -> el controlador obtiene la información necesaria del usuario -> el servicio genera el pedido prepago -> encapsulación de parámetros de clase de entidad -> WxUtil inicia el pedido unificado -> devolver el resultado

Me tomó 2 meses ordenar un conjunto de materiales técnicos de desarrollo de JAVA, cubriendo los conceptos básicos de Java, microservicios distribuidos y otros materiales técnicos principales, incluida la experiencia cara a cara de Dachang, notas de estudio, folletos de código fuente, combate real del proyecto y videos explicativos.

 

 Espero poder ayudar a algunos amigos que quieran mejorar sus habilidades a través del autoaprendizaje, obtengan la información, escaneen el código y presten atención.

Recuerda estar atento a la cuenta oficial [ Brother Coding ]

Obtenga más materiales de aprendizaje

Supongo que te gusta

Origin blog.csdn.net/qq_19007169/article/details/123628306
Recomendado
Clasificación