package com.ruoyi.app.pay;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.ruoyi.app.order.dto.OrderPushBodyDto;
import com.ruoyi.app.utils.PayPush;
import com.ruoyi.app.utils.event.PushEventService;
import com.ruoyi.app.utils.newebpay.NewebPay;
import com.ruoyi.app.utils.newebpay.NewebPayConfig;
import com.ruoyi.app.utils.newebpay.NewebPayEncryptUtil;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.annotation.RepeatSubmit;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.MessageUtils;
import com.ruoyi.system.domain.InfoUser;
import com.ruoyi.system.domain.IpnLog;
import com.ruoyi.system.domain.PosOrder;
import com.ruoyi.system.domain.PosOrderPayment;
import com.ruoyi.system.domain.PosStoreNewebpay;
import com.ruoyi.system.mapper.RiderPositionMapper;
import com.ruoyi.system.service.IInfoUserService;
import com.ruoyi.system.service.IIpnLogService;
import com.ruoyi.system.service.IPosOrderPaymentService;
import com.ruoyi.system.service.IPosOrderService;
import com.ruoyi.system.service.IPosStoreNewebpayService;
import com.ruoyi.system.utils.Auth;
import com.ruoyi.system.utils.JwtUtil;
import com.ruoyi.system.utils.OrderLogHelper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Enumeration;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 蓝新金流(NewebPay) 线上支付 Controller。
*
*
本期实现:
*
* - {@code POST /pay/newebpay/create} — 发起 MPG 幕前支付(US1)。
* - {@code POST /pay/newebpay/notify} — NotifyURL 支付结果回调(US2,@Anonymous)。
* - {@code GET|POST /pay/newebpay/return} — ReturnURL 完成页引导(US2,@Anonymous)。
*
* 单笔查询 /pay/newebpay/query(US5)后续补充。
*
* 回调成功后的业务链路(更新 payStatus + 订单日志 + 推送用户/商家/骑手)参照
*
* @author ruoyi
* @date 2026-06-22
*/
@RestController
@RequestMapping("/pay/newebpay")
public class NewebpayPayController extends BaseController {
private static final Logger log = LoggerFactory.getLogger(NewebpayPayController.class);
/** payType 取值:蓝新在线支付(发起时写入 pos_order.pay_type;具体方式 CREDIT/LINEPAY/APPLEPAY 由回调写入 pos_order_payment)。 */
public static final String PAY_TYPE_NEWEBPAY = "6";
@Autowired
private IPosOrderService posOrderService;
@Autowired
private IPosStoreNewebpayService posStoreNewebpayService;
@Autowired
private IPosOrderPaymentService posOrderPaymentService;
@Autowired
private NewebPay newebPay;
@Autowired
private IInfoUserService infoUserService;
@Autowired
private RiderPositionMapper riderPositionMapper;
@Autowired
private PushEventService pushEventService;
@Autowired
private OrderLogHelper orderLogHelper;
@Autowired
private IIpnLogService ipnLogService;
/** 复用 PayController 的可接单骑手异步推送(避免重写复杂调度)。 */
@Value("${newebpay.base-url}")
private String baseUrl;
@Value("${newebpay.notify-url}")
private String notifyUrl;
@Value("${newebpay.return-url}")
private String returnUrl;
@Value("${newebpay.front-result-url}")
private String frontResultUrl;
@Value("${newebpay.mpg-version}")
private String mpgVersion;
// ============================ US1:发起 MPG 幕前支付 ============================
/**
* 发起蓝新 MPG 幕前支付。校验订单 → 查门店启用凭证 → 生成商店订单号 →
* 组装并加密 TradeInfo → 落流水 + 更新订单 payType → 返回 form 字段供前端 Form Post 跳转蓝新付款页。
*/
@Anonymous
@Auth
@RepeatSubmit(interval = 1000, message = "请求过于频繁")
@PostMapping("/create")
public AjaxResult create(@RequestParam String orderid, HttpServletRequest request) {
String token = request.getHeader("token");
JwtUtil jwtUtil = new JwtUtil();
String userId;
try {
userId = jwtUtil.getusid(token);
} catch (Exception e) {
return error(MessageUtils.message("no.order.id.error"));
}
if (userId == null || userId.isEmpty()) {
return error("请先登录");
}
PosOrder order = posOrderService.getOne(new QueryWrapper().eq("dd_id", orderid));
if (order == null) {
return error(MessageUtils.message("no.order.id.error"));
}
if (order.getUserId() == null || !userId.equals(String.valueOf(order.getUserId()))) {
return error("无权操作该订单");
}
if (order.getPayStatus() != null && order.getPayStatus() == 1L) {
return error("订单已支付");
}
if (order.getAmount() == null || order.getAmount() <= 0) {
return error("订单金额异常");
}
Long storeId = order.getMdId();
PosStoreNewebpay cfg = posStoreNewebpayService.getEnabledConfig(storeId);
if (cfg == null) {
return error("该门店暂不支持线上支付");
}
String merchantOrderNo = genMerchantOrderNo(orderid);
// TradeInfo 明文(NDNF §4.2.1)
Map tradeParams = new LinkedHashMap<>();
tradeParams.put("MerchantID", cfg.getMerchantId());
tradeParams.put("RespondType", "JSON");
tradeParams.put("TimeStamp", String.valueOf(System.currentTimeMillis() / 1000L));
tradeParams.put("Version", mpgVersion);
tradeParams.put("MerchantOrderNo", merchantOrderNo);
tradeParams.put("Amt", order.getAmount());
tradeParams.put("ItemDesc", "order " + orderid);
tradeParams.put("NotifyURL", notifyUrl);
tradeParams.put("ReturnURL", returnUrl);
applyPaymentSwitch(tradeParams, cfg.getEnabledPayments());
NewebPayConfig npc = new NewebPayConfig(cfg.getMerchantId(), cfg.getHashKey(), cfg.getHashIv());
Map form = newebPay.createMpgForm(baseUrl, npc, tradeParams, mpgVersion);
posOrderPaymentService.createPayment(orderid, merchantOrderNo, storeId, cfg.getMerchantId(), order.getAmount());
PosOrder upd = new PosOrder();
upd.setId(order.getId());
upd.setPayType(PAY_TYPE_NEWEBPAY);
upd.setPayUrl(form.get("gatewayUrl"));
posOrderService.saveOrUpdate(upd);
return success(MessageUtils.message("no.order.create.success"), form);
}
// ============================ US2:NotifyURL 支付结果回调 ============================
/**
* 蓝新支付结果背景通知(@Anonymous,蓝新服务器 Form Post)。
*
* 记 IPN 日志 → 按 MerchantID 查凭证 → 验签 TradeSha → 解密 TradeInfo →
* 幂等(trade_no) + 金额校验 + 订单关联 → 成功则更新 payStatus=1 并推送用户/商家/骑手;
* 失败则记录。任何异常都不抛错响应(蓝新收到非成功会重试),但绝不错误更新订单。
*/
@Anonymous
@PostMapping("/notify")
public JSONObject notify(HttpServletRequest request) {
JSONObject resp = new JSONObject();
resp.put("Status", "SUCCESS");
resp.put("Message", "OK");
Map form = collectForm(request);
String ip = new com.ruoyi.app.utils.IpUtils().getIpAddr(request);
String merchantId = (String) form.get("MerchantID");
String tradeInfo = (String) form.get("TradeInfo");
String tradeSha = (String) form.get("TradeSha");
String status = (String) form.get("Status");
// 记录 IPN 日志
try {
IpnLog ipnLog = new IpnLog();
ipnLog.setIp(ip);
ipnLog.setIpnLog(form.toString());
ipnLogService.insertIpnLog(ipnLog);
} catch (Exception e) {
log.warn("记蓝新 IPN 日志失败", e);
}
PosStoreNewebpay cfg = posStoreNewebpayService.getByMerchantId(merchantId);
if (cfg == null || cfg.getHashKey() == null || tradeInfo == null) {
log.warn("蓝新回调无匹配凭证或缺少 TradeInfo: merchantId={}", merchantId);
return resp;
}
// 验签
String expectedSha = NewebPayEncryptUtil.genTradeSha(tradeInfo, cfg.getHashKey(), cfg.getHashIv());
if (tradeSha == null || !tradeSha.equals(expectedSha)) {
log.warn("蓝新回调验签失败: merchantId={}, expected={}, got={}", merchantId, expectedSha, tradeSha);
return resp;
}
// 解密
Map d;
try {
d = parseQuery(NewebPayEncryptUtil.decrypt(tradeInfo, cfg.getHashKey(), cfg.getHashIv()));
} catch (Exception e) {
log.warn("蓝新回调解密失败: merchantId={}", merchantId, e);
return resp;
}
String tradeNo = getStr(d, "TradeNo");
String merchantOrderNo = getStr(d, "MerchantOrderNo");
// 幂等:同 tradeNo 已成功则直接返回
PosOrderPayment exist = posOrderPaymentService.getByTradeNo(tradeNo);
if (exist != null && exist.getPayStatus() != null && exist.getPayStatus() == 1) {
return resp;
}
// 订单关联
PosOrderPayment payment = posOrderPaymentService.getByMerchantOrderNo(merchantOrderNo);
if (payment == null) {
log.warn("蓝新回调无对应发起记录: merchantOrderNo={}", merchantOrderNo);
return resp;
}
String ddId = payment.getDdId();
PosOrder order = posOrderService.getOne(new QueryWrapper().eq("dd_id", ddId));
if (order == null) {
log.warn("蓝新回调订单不存在: ddId={}", ddId);
return resp;
}
// 金额校验
int amt = parseInt(getStr(d, "Amt"), -1);
if (amt < 0 || order.getAmount() == null || order.getAmount() != amt) {
log.error("蓝新回调金额不符: ddId={}, orderAmt={}, callbackAmt={}", ddId, order.getAmount(), amt);
return resp;
}
String payType = mapPaymentType(d);
String auth = getStr(d, "Auth");
Date payTime = parsePayTime(getStr(d, "PayTime"));
if ("SUCCESS".equals(status)) {
int n = posOrderPaymentService.markSuccess(ddId, merchantOrderNo, payment.getStoreId(),
merchantId, amt, tradeNo, payType, auth, payTime, d.toString());
if (n > 0) {
// 首次成功:更新订单支付状态并推送(state 保持 0 待商家接单)
handlePaymentSuccess(order, d);
}
} else {
posOrderPaymentService.markFail(merchantOrderNo, tradeNo, payType, d.toString());
log.warn("蓝新回调交易失败: ddId={}, status={}, message={}", ddId, status, getStr(d, "Message"));
}
return resp;
}
/**
* 支付完成返回页(@Anonymous)。仅引导回前端结果页,不修改订单状态(状态以 notify 为准)。
*/
@Anonymous
@RequestMapping(value = "/return", method = {org.springframework.web.bind.annotation.RequestMethod.GET,
org.springframework.web.bind.annotation.RequestMethod.POST})
public void returnCallback(HttpServletRequest request, HttpServletResponse response) throws IOException {
String ddId = "";
try {
Map form = collectForm(request);
String merchantId = (String) form.get("MerchantID");
String tradeInfo = (String) form.get("TradeInfo");
PosStoreNewebpay cfg = posStoreNewebpayService.getByMerchantId(merchantId);
if (cfg != null && cfg.getHashKey() != null && tradeInfo != null) {
Map d = parseQuery(NewebPayEncryptUtil.decrypt(tradeInfo, cfg.getHashKey(), cfg.getHashIv()));
PosOrderPayment p = posOrderPaymentService.getByMerchantOrderNo(getStr(d, "MerchantOrderNo"));
if (p != null) {
ddId = p.getDdId();
}
}
} catch (Exception e) {
log.warn("蓝新 ReturnURL 解析失败", e);
}
String sep = frontResultUrl.contains("?") ? "&" : "?";
response.sendRedirect(frontResultUrl + sep + "ddId=" + URLEncoder.encode(ddId, StandardCharsets.UTF_8));
}
// ============================ 支付成功业务链路(参照 PayController.payipn) ============================
/** 支付成功:更新订单 payStatus=1(state 不变)+ 订单日志 + 推送用户/商家/骑手。 */
private void handlePaymentSuccess(PosOrder order, Map decryptedMap) {
try {
PosOrder upd = new PosOrder();
upd.setId(order.getId());
upd.setState(0L);
upd.setPayStatus(1L);
posOrderService.saveOrUpdate(upd);
orderLogHelper.logSync(String.valueOf(order.getDdId()), 0, null, "系统", "系统收到蓝新支付成功回调");
InfoUser user = order.getUserId() == null ? null : infoUserService.getById(order.getUserId());
InfoUser shanghu = order.getShId() == null ? null : infoUserService.getById(order.getShId());
if (user == null) {
return;
}
PayPush push = new PayPush();
Map orderMap = OrderPushBodyDto.getMap(String.valueOf(order.getDdId()), "1", 0, 1);
String json = OrderPushBodyDto.margeMapGetJsonString(orderMap, decryptedMap);
String title = MessageUtils.message("no.message.push.message");
push.apppush(user.getCid(), title, MessageUtils.message("no.message.push.payment.success"), json);
pushEventService.PublisherEvent(user.getUserId(), title, MessageUtils.message("no.message.push.payment.success"), json);
if (shanghu != null) {
push.shpush(shanghu.getCid(), title, MessageUtils.message("no.message.push.new.order"), json);
pushEventService.PublisherEvent(shanghu.getUserId(), title, MessageUtils.message("no.message.push.new.order"), json);
}
// 可接单骑手推送(外卖),复用 PayController 的异步实现
try {
String body = OrderPushBodyDto.getJson(String.valueOf(order.getDdId()), String.valueOf(order.getState()), 1);
//推送可接单骑手
} catch (Exception e) {
log.warn("蓝新支付成功骑手推送异常: ddId={}", order.getDdId(), e);
}
} catch (Exception e) {
log.error("蓝新支付成功业务处理异常: ddId={}", order.getDdId(), e);
}
}
// ============================ 辅助 ============================
private static String genMerchantOrderNo(String orderid) {
String safe = orderid == null ? "" : orderid.replaceAll("[^A-Za-z0-9_]", "");
return "NB" + safe;
}
private static void applyPaymentSwitch(Map params, String enabledPayments) {
if (enabledPayments == null || enabledPayments.isEmpty()) {
params.put("CREDIT", 1);
return;
}
String eps = enabledPayments.toUpperCase();
params.put("CREDIT", eps.contains("CREDIT") ? 1 : 0);
params.put("LINEPAY", eps.contains("LINEPAY") ? 1 : 0);
params.put("APPLEPAY", eps.contains("APPLEPAY") ? 1 : 0);
}
/** 收集 form 参数到 Map(值非空的单值参数)。 */
private static Map collectForm(HttpServletRequest request) {
Map m = new LinkedHashMap<>();
Enumeration names = request.getParameterNames();
while (names.hasMoreElements()) {
String n = names.nextElement();
String[] vals = request.getParameterValues(n);
if (vals != null && vals.length == 1 && !vals[0].isEmpty()) {
m.put(n, vals[0]);
}
}
return m;
}
/** 解析 a=1&b=2(URL decode)为 Map。 */
private static Map parseQuery(String query) {
Map m = new LinkedHashMap<>();
if (query == null || query.isEmpty()) {
return m;
}
for (String pair : query.split("&")) {
int idx = pair.indexOf('=');
if (idx <= 0) {
continue;
}
String k = urlDecode(pair.substring(0, idx));
String v = idx + 1 <= pair.length() ? urlDecode(pair.substring(idx + 1)) : "";
m.put(k, v);
}
return m;
}
private static String urlDecode(String s) {
try {
return URLDecoder.decode(s, StandardCharsets.UTF_8);
} catch (Exception e) {
return s;
}
}
private static String getStr(Map m, String k) {
Object v = m.get(k);
return v == null ? "" : v.toString();
}
private static int parseInt(String s, int def) {
try {
return Integer.parseInt(s);
} catch (Exception e) {
return def;
}
}
private static Date parsePayTime(String s) {
if (s == null || s.isEmpty()) {
return null;
}
try {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(s);
} catch (Exception e) {
return null;
}
}
/** 蓝新回调支付方式映射:优先 PaymentMethod(APPLEPAY),否则 PaymentType(CREDIT/LINEPAY...)。 */
private static String mapPaymentType(Map d) {
String pm = getStr(d, "PaymentMethod");
if (!pm.isEmpty()) {
return pm.toUpperCase();
}
return getStr(d, "PaymentType").toUpperCase();
}
// TODO US5: POST /pay/newebpay/query(单笔查询 + 补单)
}