feat(mp-java): hjc 小程序接入链、微信接入自检、首页轮播与一站式出向推送
hjc 包的进展(按 AGENTS.md 约定,改动集中在 hjc 包内):
- HjcWechatController 新增三接口:
- GET /api/hjc/wechat/readiness 管理员自检,逐项报告小程序/公众号/支付/serverUrl
是否配好、支付 appId 与小程序 appId 是否一致;?probe=true 实调微信验证 appSecret
(失败只记日志、不抛异常)。appId 与商户号在 detail 里打码,appSecret 永不输出。
- GET /api/hjc/wechat/mp-appid 只回 appId,供 H5 渲染开放标签(不含密钥)。
- POST /api/hjc/wechat/mp-login uni.login 的 code 换小程序 openid/unionid;匿名放行,
租户取 HjcAuthProperties 的配置值而非可伪造的请求头。
配套 HjcWechatReadinessUtil(含单测)与 HjcWechatController 的两套配置读取:
公众号 cache{t}:setting:wx-official、小程序 mp-weixin:{t} → setting:mp-weixin:{t} →
跨库回源 gxwebsoft_core.sys_setting。
- HjcBannerController / Service / ServiceImpl / Mapper(+XML) / HjcBannerVo:只读
GET /api/hjc/banner/list,按租户 + position + 启用状态 + 生效时间窗口过滤 CMS
轮播组并扁平化;租户隔离交给 MyBatis-Plus 租户拦截器,不改 cms 及其他项目代码。
配套 HjcBannerApiTest(最小上下文 MockMvc,含不串租户与位置过滤)。
- 一站式出向推送:CreatePurchaseDetails 补退款字段,HjcOrder 补 refund_time /
refund_reason(配套 hjc_order_add_refund.sql 与 hjc_init.sql),HjcBizServiceImpl
在 REFUNDED 时把退款时间与原因一并推送;配套推送报文单测。
- 共享文件 SecurityConfig.java 仅追加一行:匿名放行 /api/hjc/wechat/mp-login
(注册页证件上传与 OCR 早先已放行)。这是本包唯一改到 common 的地方。
- 文档:CONTEXT.md、docs/一站式平台对接-接口文档.md。
**不含** scripts/hjc_test_push_out.py:该联调探针脚本内含与 HjcOneStopAuthUtil.java
相同的 APP_KEY / PASSWORD 明文,按此前排查记录「不得提交」处理,保留在工作区未跟踪。
This commit is contained in:
@@ -90,7 +90,10 @@ public class SecurityConfig {
|
||||
"/api/hjc/auth/sms",
|
||||
// 注册页专用:证件上传与 OCR 识别在登录前发生
|
||||
"/api/hjc/auth/upload",
|
||||
"/api/hjc/ocr/recognize"
|
||||
"/api/hjc/ocr/recognize",
|
||||
// 小程序端登录:用 uni.login 的 code 换 openid,发生在业务请求之前,
|
||||
// 小程序侧此时还没有平台 token
|
||||
"/api/hjc/wechat/mp-login"
|
||||
)
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.gxwebsoft.hjc.controller;
|
||||
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
import com.gxwebsoft.hjc.dto.HjcBannerVo;
|
||||
import com.gxwebsoft.hjc.service.HjcBannerService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 汇吉采首页轮播图(C 端只读)
|
||||
*
|
||||
* <p>数据源为 CMS 后台维护的轮播组(cms_banner_group / cms_banner_item),
|
||||
* 运营在管理后台配置图片、跳转与生效时间即可生效,无需改前端代码。
|
||||
* 仅提供 GET,未登录也可访问(用于首页首屏)。</p>
|
||||
*/
|
||||
@Tag(name = "汇吉采-首页轮播")
|
||||
@RestController
|
||||
@RequestMapping("/api/hjc/banner")
|
||||
public class HjcBannerController extends BaseController {
|
||||
|
||||
@Resource
|
||||
private HjcBannerService hjcBannerService;
|
||||
|
||||
@Operation(summary = "启用中的轮播图列表(C端)")
|
||||
@GetMapping("/list")
|
||||
public ApiResult<List<HjcBannerVo>> list(
|
||||
@RequestParam(value = "position", required = false) String position) {
|
||||
return success(hjcBannerService.listEnabled(position));
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,12 @@ import com.gxwebsoft.hjc.service.HjcEnterpriseService;
|
||||
import com.gxwebsoft.hjc.service.HjcOrderService;
|
||||
import com.gxwebsoft.payment.dto.PaymentRequest;
|
||||
import com.gxwebsoft.payment.dto.PaymentResponse;
|
||||
import com.gxwebsoft.payment.enums.PaymentStatus;
|
||||
import com.gxwebsoft.payment.enums.PaymentType;
|
||||
import com.gxwebsoft.payment.service.PaymentService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -30,6 +32,7 @@ import javax.annotation.Resource;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -37,6 +40,7 @@ import java.util.Map;
|
||||
* 汇吉采标书订单(下单 + 我的订单 + 后台订单)
|
||||
*/
|
||||
@Tag(name = "汇吉采-标书订单")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/hjc/order")
|
||||
public class HjcOrderController extends BaseController {
|
||||
@@ -209,16 +213,50 @@ public class HjcOrderController extends BaseController {
|
||||
}
|
||||
// 归属校验:订单详情只对「订单所属企业的买家」或 hjc 管理员开放。
|
||||
// 此前没有任何校验,且 GET 全放行,等于任何人(含匿名)都能按 id 遍历读取全部订单。
|
||||
if (!hjcGuard.isAdmin()) {
|
||||
HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(loginUser.getUserId());
|
||||
if (enterprise == null || !enterprise.getId().equals(order.getEnterpriseId())) {
|
||||
return HjcAuthResponses.forbidden("无权查看该订单");
|
||||
}
|
||||
ApiResult<?> denied = checkReadable(loginUser, order);
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
order.setProject(hjcBidProjectService.getById(order.getProjectId()));
|
||||
return success(order);
|
||||
}
|
||||
|
||||
@Operation(summary = "按订单号查订单(收银台只有订单号,用它取应付金额)")
|
||||
@GetMapping("/by-no/{orderNo}")
|
||||
public ApiResult<?> detailByOrderNo(@PathVariable("orderNo") String orderNo) {
|
||||
// 与 detail 同序:先判身份、再判存在、最后判归属,避免新增一条能绕过归属校验的读取路径
|
||||
User loginUser = hjcGuard.currentUser();
|
||||
if (loginUser == null) {
|
||||
return HjcAuthResponses.unauthorized();
|
||||
}
|
||||
HjcOrder order = hjcOrderService.getByOrderNo(orderNo);
|
||||
if (order == null) {
|
||||
return fail("订单不存在");
|
||||
}
|
||||
ApiResult<?> denied = checkReadable(loginUser, order);
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
// 收银台只用来展示应付金额,不回带 project(与 detail 的区别),少一次查询
|
||||
return success(order);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单读取的归属校验:买家只能读本企业的订单,hjc 管理员放行。
|
||||
*
|
||||
* @return 不可读时返回错误响应;可读时返回 null
|
||||
*/
|
||||
private ApiResult<?> checkReadable(User loginUser, HjcOrder order) {
|
||||
if (hjcGuard.isAdmin()) {
|
||||
return null;
|
||||
}
|
||||
HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(loginUser.getUserId());
|
||||
if (enterprise == null || !enterprise.getId().equals(order.getEnterpriseId())) {
|
||||
return HjcAuthResponses.forbidden("无权查看该订单");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Operation(summary = "后台-订单分页")
|
||||
@GetMapping("/page")
|
||||
@PreAuthorize("@hjcGuard.isAdmin()")
|
||||
@@ -226,7 +264,7 @@ public class HjcOrderController extends BaseController {
|
||||
return success(hjcOrderService.pageRel(param));
|
||||
}
|
||||
|
||||
@Operation(summary = "退款(标记已退款,幂等),并推送 REFUNDED 状态")
|
||||
@Operation(summary = "退款(标记已退款,记录退款时间/退款原因,幂等),并推送 REFUNDED 状态")
|
||||
@PostMapping("/refund")
|
||||
@PreAuthorize("@hjcGuard.isAdmin()")
|
||||
public ApiResult<?> refund(@RequestBody Map<String, Object> body) {
|
||||
@@ -241,10 +279,68 @@ public class HjcOrderController extends BaseController {
|
||||
if (order.getPayStatus() != null && order.getPayStatus() == 3) {
|
||||
return success("已是退款状态", order);
|
||||
}
|
||||
order.setPayStatus(3);
|
||||
hjcOrderService.updateById(order);
|
||||
hjcBizService.pushOrderToOneStop(order);
|
||||
return success("已退款", order);
|
||||
// 退款原因随 REFUNDED 状态推送给一站式(必带字段),为空时由服务端兜底默认原因
|
||||
String refundReason = body.get("refundReason") == null ? null : String.valueOf(body.get("refundReason"));
|
||||
return success("已退款", hjcBizService.refund(orderNo, refundReason));
|
||||
}
|
||||
|
||||
/**
|
||||
* 向微信查单,返回**微信侧**的真实支付状态;若微信已确认支付成功,则**顺带修复本地订单状态**。
|
||||
*
|
||||
* <p><b>为什么需要这个接口</b>:客户端在 JSAPI/requestPayment 失败后只能看到一句错误
|
||||
* (用户取消、密码错、还是其实已经付成功了,前端分不清)。而「已支付」是事实,
|
||||
* 应当以微信侧为准去查,而不是由前端调 {@code /mark-paid} 自说自话。</p>
|
||||
*
|
||||
* <p><b>为什么这里要写库(而不只是查)</b>:汇吉采的支付结果回写<b>完全依赖前端</b>——
|
||||
* 微信回调 {@code PaymentNotifyController} 只做验签与应答,不认识 hjc 订单;
|
||||
* 订单置为已支付只有 {@code /mark-paid} 一条路。于是「用户付了钱但页面被关掉/断网」
|
||||
* 就会留下一笔已收款却仍显示待支付的订单。这里是唯一以微信侧为准的入口,
|
||||
* 因此在确认 {@code SUCCESS} 时就地调 {@code markPaid} 补齐状态(幂等,且会触发一站式推送)。</p>
|
||||
*
|
||||
* <p>安全性:查询前已做「必须登录 + 订单归属校验」,故不存在越权改他人订单状态的问题。</p>
|
||||
*/
|
||||
@Operation(summary = "查询微信侧支付状态;已支付则顺势修复本单状态")
|
||||
@GetMapping("/pay-status/{orderNo}")
|
||||
public ApiResult<?> payStatus(@PathVariable("orderNo") String orderNo) {
|
||||
User loginUser = getLoginUser();
|
||||
if (loginUser == null) {
|
||||
return HjcAuthResponses.unauthorized();
|
||||
}
|
||||
HjcOrder order = hjcOrderService.getByOrderNo(orderNo);
|
||||
if (order == null) {
|
||||
return fail("订单不存在");
|
||||
}
|
||||
ApiResult<?> unreadable = checkReadable(loginUser, order);
|
||||
if (unreadable != null) {
|
||||
return unreadable;
|
||||
}
|
||||
try {
|
||||
PaymentResponse resp = paymentService.queryPayment(
|
||||
orderNo, PaymentType.WECHAT, order.getTenantId());
|
||||
String status = resp == null || resp.getPaymentStatus() == null
|
||||
? null : resp.getPaymentStatus().name();
|
||||
boolean repaired = false;
|
||||
// 微信说成功了,本地还是未支付 —— 说明前端那次 mark-paid 没送达,这里补齐
|
||||
if (PaymentStatus.SUCCESS.name().equals(status)
|
||||
&& (order.getPayStatus() == null || order.getPayStatus() != 1)) {
|
||||
try {
|
||||
hjcBizService.markPaid(orderNo);
|
||||
repaired = true;
|
||||
log.info("HjcOrder: 查单发现微信已支付,已补写本地订单状态 orderNo={}", orderNo);
|
||||
} catch (Exception e) {
|
||||
// 补写失败不影响本次查询结论:前端仍会看到 SUCCESS
|
||||
log.warn("HjcOrder: 补写本地订单状态失败 orderNo={}", orderNo, e);
|
||||
}
|
||||
}
|
||||
Map<String, Object> data = new HashMap<>(6);
|
||||
data.put("orderNo", orderNo);
|
||||
data.put("paymentStatus", status);
|
||||
data.put("transactionId", resp == null ? null : resp.getTransactionId());
|
||||
data.put("repaired", repaired);
|
||||
return success("查询成功", data);
|
||||
} catch (com.gxwebsoft.payment.exception.PaymentException e) {
|
||||
return fail("查询支付状态失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String firstNotBlank(String a, String b) {
|
||||
|
||||
@@ -7,32 +7,55 @@ import com.alibaba.fastjson.JSONObject;
|
||||
import com.gxwebsoft.common.core.config.ConfigProperties;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
import com.gxwebsoft.common.system.entity.Payment;
|
||||
import com.gxwebsoft.hjc.auth.HjcAuthProperties;
|
||||
import com.gxwebsoft.hjc.util.HjcWechatReadinessUtil;
|
||||
import com.gxwebsoft.payment.service.WxPayConfigService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.sql.DataSource;
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 汇吉采微信公众号 H5 对接:网页授权(取 openid) 与 JS-SDK 签名(用于公众号内唤起支付)。
|
||||
* 汇吉采微信对接:公众号 H5(网页授权取 openid、JS-SDK 签名)与小程序(code 换 openid)。
|
||||
*
|
||||
* 依赖公众号配置:Redis 键 cache{tenantId}:setting:wx-official -> {"appId":"...","appSecret":"..."}
|
||||
* <p>两套配置分别来自 Redis:</p>
|
||||
* <ul>
|
||||
* <li>公众号:{@code cache{tenantId}:setting:wx-official} → {@code {"appId","appSecret"}}</li>
|
||||
* <li>小程序:{@code mp-weixin:{tenantId}} → {@code {"appId","appSecret"}}(与平台通用小程序配置同一份)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>两套 openid 不可互用</b>:小程序 openid 属于小程序 appid,公众号网页授权 openid 属于
|
||||
* 公众号 appid;微信支付 JSAPI 下单要求 openid 与商户号绑定的 appid 同源,故两条链路各自取各自的
|
||||
* openid,服务端不做换算。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
* @since 2026-09
|
||||
*/
|
||||
@Tag(name = "汇吉采-微信公众号")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/hjc/wechat")
|
||||
public class HjcWechatController extends BaseController {
|
||||
@@ -43,14 +66,128 @@ public class HjcWechatController extends BaseController {
|
||||
private static final String JSAPI_TICKET_KEY = "wx:jsapi:ticket:";
|
||||
private static final long EXPIRE_SECONDS = 7000L;
|
||||
|
||||
/** 小程序配置的 Redis 键前缀(与平台通用小程序配置一致,见 RedisConstants.MP_WX_KEY) */
|
||||
private static final String MP_SETTING_KEY_PREFIX = "mp-weixin:";
|
||||
/** 小程序在 sys_setting 里的 setting_key */
|
||||
private static final String MP_WEIXIN_SETTING_KEY = "mp-weixin";
|
||||
|
||||
@Resource
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
@Resource
|
||||
private ConfigProperties configProperties;
|
||||
@Resource
|
||||
private HjcAuthProperties hjcAuthProperties;
|
||||
/** 自检用:读取支付配置(跨库 sys_payment)以核对 appId 一致性 */
|
||||
@Resource
|
||||
private WxPayConfigService wxPayConfigService;
|
||||
/** 用于回源读取跨库的 sys_setting(小程序配置在 Redis 缺失时的兜底) */
|
||||
@Resource
|
||||
private DataSource dataSource;
|
||||
|
||||
@Value("${spring.profiles.active:dev}")
|
||||
private String activeProfile;
|
||||
|
||||
@Operation(summary = "微信接入自检(只读;管理员;排查「配置到底缺哪一项」)")
|
||||
@GetMapping("/readiness")
|
||||
@PreAuthorize("@hjcGuard.isAdmin()")
|
||||
public ApiResult<?> readiness(@RequestParam(value = "probe", required = false) Boolean probe) {
|
||||
Integer tenantId = hjcAuthProperties.getTenantId();
|
||||
List<Map<String, Object>> checks = new ArrayList<>();
|
||||
boolean ok = true;
|
||||
|
||||
// 1) 小程序配置:mp-login 要用它换 openid
|
||||
WxMpConfig mp = loadMpConfig();
|
||||
ok &= HjcWechatReadinessUtil.addCheck(checks, "小程序 appId/appSecret", mp != null,
|
||||
"小程序端 code 换 openid(POST /api/hjc/wechat/mp-login)依赖它;"
|
||||
+ "缺失则小程序端拿不到 openid,支付流程第一步即失败",
|
||||
mp == null ? "在后台「小程序配置」保存 appId + appSecret"
|
||||
: "appId=" + HjcWechatReadinessUtil.mask(mp.appId));
|
||||
|
||||
// 2) 公众号配置:H5 的网页授权与 JS-SDK 签名(开放标签的 wx.config 也用它)
|
||||
WxOfficialConfig off = loadConfig();
|
||||
ok &= HjcWechatReadinessUtil.addCheck(checks, "公众号 appId/appSecret", off != null,
|
||||
"H5 的网页授权与 JS-SDK 签名依赖它;缺失则 wx.config 失败,"
|
||||
+ "开放标签不会渲染(收银台退回扫码,而微信内扫码不可用)",
|
||||
off == null ? "配置 Redis 键 " + settingKey(tenantId)
|
||||
: "appId=" + HjcWechatReadinessUtil.mask(off.appId));
|
||||
|
||||
// 3) 后端公网地址:网页授权回调域名要指到它
|
||||
String serverUrl = configProperties.getServerUrl();
|
||||
boolean serverUrlOk = HjcWechatReadinessUtil.notBlank(serverUrl);
|
||||
ok &= HjcWechatReadinessUtil.addCheck(checks, "后端公网地址 serverUrl", serverUrlOk,
|
||||
"网页授权回调 redirect_uri 由它拼出;为空则授权地址不可用",
|
||||
serverUrlOk ? serverUrl : "配置 ConfigProperties.serverUrl");
|
||||
|
||||
// 4) 支付配置(跨库 gxwebsoft_core.sys_payment,走 Payment:1:{tenant} 缓存)
|
||||
Payment pay = null;
|
||||
String payErr = null;
|
||||
try {
|
||||
pay = wxPayConfigService.getPaymentConfigForStrategy(tenantId);
|
||||
} catch (Exception e) {
|
||||
payErr = e.getMessage();
|
||||
}
|
||||
boolean payOk = pay != null && HjcWechatReadinessUtil.notBlank(pay.getMchId())
|
||||
&& HjcWechatReadinessUtil.notBlank(pay.getAppId());
|
||||
ok &= HjcWechatReadinessUtil.addCheck(checks, "微信支付商户号/appId", payOk,
|
||||
"JSAPI 下单依赖它;缺失则 /order/pay 直接失败",
|
||||
payOk ? "mchId=" + HjcWechatReadinessUtil.mask(pay.getMchId()) + " appId=" + HjcWechatReadinessUtil.mask(pay.getAppId())
|
||||
: (payErr != null ? payErr : "在后台配置微信支付"));
|
||||
|
||||
// 5) 关键一致性:支付 appId 必须与小程序 appId 相同。
|
||||
// 微信要求「openid 与商户号绑定的 appid 同源」——本方案里 openid 来自小程序,
|
||||
// 所以两者不同值必然导致 APPID_MCHID_NOT_MATCH / appid 与 openid 不匹配。
|
||||
if (payOk && mp != null) {
|
||||
boolean same = HjcWechatReadinessUtil.sameAppId(pay.getAppId(), mp.appId);
|
||||
ok &= HjcWechatReadinessUtil.addCheck(checks, "支付 appId 与小程序 appId 一致", same,
|
||||
"JSAPI 要求 openid 与商户号绑定的 appid 同源;不一致会在下单时被微信拒绝"
|
||||
+ "(APPID_MCHID_NOT_MATCH 或 openid 不匹配)",
|
||||
"支付=" + HjcWechatReadinessUtil.mask(pay.getAppId()) + " 小程序=" + HjcWechatReadinessUtil.mask(mp.appId)
|
||||
+ (same ? "" : " ← 需改成同一个 appId"));
|
||||
}
|
||||
|
||||
// 6) 可选:真去微信取一次接口凭据/票据,验证 appSecret 有效且域名可达
|
||||
if (Boolean.TRUE.equals(probe)) {
|
||||
if (mp != null) {
|
||||
ok &= HjcWechatReadinessUtil.addCheck(checks, "小程序 appSecret 有效性(实调微信)",
|
||||
probeAccessToken(mp.appId, mp.appSecret),
|
||||
"appSecret 错误或小程序未开通时,jscode2session 会失败",
|
||||
"见日志");
|
||||
}
|
||||
if (off != null) {
|
||||
ok &= HjcWechatReadinessUtil.addCheck(checks, "公众号 appSecret 有效性(实调微信)",
|
||||
probeAccessToken(off.appId, off.appSecret),
|
||||
"appSecret 错误时取不到 access_token,JS-SDK 签名必然失败",
|
||||
"见日志");
|
||||
}
|
||||
}
|
||||
|
||||
return success(ok ? "配置就绪" : "存在未就绪项,见 checks",
|
||||
HjcWechatReadinessUtil.summary(tenantId, ok, checks));
|
||||
}
|
||||
|
||||
/** 实调微信 client_credential,验证 appId/appSecret 有效(自检专用,失败只记日志) */
|
||||
private boolean probeAccessToken(String appId, String appSecret) {
|
||||
try {
|
||||
String url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid="
|
||||
+ appId + "&secret=" + appSecret;
|
||||
JSONObject json = JSONObject.parseObject(HttpUtil.get(url));
|
||||
boolean ok = json != null && json.getString("access_token") != null;
|
||||
if (!ok) {
|
||||
log.warn("HjcWechat 自检: 取 access_token 失败 appId={} errcode={} errmsg={}",
|
||||
HjcWechatReadinessUtil.mask(appId), json == null ? null : json.getString("errcode"),
|
||||
json == null ? null : json.getString("errmsg"));
|
||||
}
|
||||
return ok;
|
||||
} catch (Exception e) {
|
||||
log.warn("HjcWechat 自检: 取 access_token 异常 appId={}", HjcWechatReadinessUtil.mask(appId), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String settingKey(Integer tenantId) {
|
||||
return SETTING_KEY_PREFIX + tenantId + SETTING_KEY_SUFFIX;
|
||||
}
|
||||
|
||||
@Operation(summary = "获取公众号网页授权地址(静默 snsapi_base)")
|
||||
@GetMapping("/authorize")
|
||||
public ApiResult<?> authorize(@RequestParam(value = "redirect", required = false) String redirect) {
|
||||
@@ -101,6 +238,62 @@ public class HjcWechatController extends BaseController {
|
||||
response.sendRedirect(target);
|
||||
}
|
||||
|
||||
@Operation(summary = "获取小程序 appId(供 H5 渲染开放标签;不含任何密钥)")
|
||||
@GetMapping("/mp-appid")
|
||||
public ApiResult<?> mpAppId() {
|
||||
WxMpConfig cfg = loadMpConfig();
|
||||
if (cfg == null) {
|
||||
// 指路到具体位置:这是运维第一次跑通时唯一能看到的线索。
|
||||
// 注意 modules 库账号对 gxwebsoft_core.sys_setting 只有 SELECT 权限,
|
||||
// 直连数据库写不进去,必须走后台接口保存(它写入该表)。
|
||||
return fail("小程序配置未找到:请在管理后台「系统设置 → 小程序配置」保存 appId 与 appSecret");
|
||||
}
|
||||
Map<String, Object> data = new HashMap<>(2);
|
||||
// 只回 appId:开放标签需要它,而它是公开信息;appSecret 绝不出后端
|
||||
data.put("appId", cfg.appId);
|
||||
return success("获取成功", data);
|
||||
}
|
||||
|
||||
@Operation(summary = "小程序登录(code 换 openid)")
|
||||
@PostMapping("/mp-login")
|
||||
public ApiResult<?> mpLogin(@RequestBody Map<String, String> body) {
|
||||
String code = body == null ? null : body.get("code");
|
||||
if (code == null || code.isEmpty()) {
|
||||
return fail("code不能为空");
|
||||
}
|
||||
WxMpConfig cfg = loadMpConfig();
|
||||
if (cfg == null) {
|
||||
// 指路到具体位置:这是运维第一次跑通时唯一能看到的线索。
|
||||
// 注意 modules 库账号对 gxwebsoft_core.sys_setting 只有 SELECT 权限,
|
||||
// 直连数据库写不进去,必须走后台接口保存(它写入该表)。
|
||||
return fail("小程序配置未找到:请在管理后台「系统设置 → 小程序配置」保存 appId 与 appSecret");
|
||||
}
|
||||
try {
|
||||
// code 必须编码:它是 query 参数,含未编码的特殊字符时会把后面的参数截断/篡改
|
||||
String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + cfg.appId
|
||||
+ "&secret=" + cfg.appSecret + "&js_code=" + URLEncoder.encode(code, "UTF-8")
|
||||
+ "&grant_type=authorization_code";
|
||||
String respBody = HttpUtil.get(url);
|
||||
JSONObject json = JSONObject.parseObject(respBody);
|
||||
String openid = json.getString("openid");
|
||||
if (openid == null || openid.isEmpty()) {
|
||||
// errcode 40029=code 无效(常见于重复使用)、45011=频率限制、40163=code 已被使用
|
||||
log.warn("HjcWechat: 小程序 code 换 openid 失败 errcode={} errmsg={}",
|
||||
json.getString("errcode"), json.getString("errmsg"));
|
||||
return fail("微信登录失败:" + json.getString("errmsg"));
|
||||
}
|
||||
Map<String, Object> data = new HashMap<>(4);
|
||||
data.put("openid", openid);
|
||||
// unionid 仅在开放平台账号绑定后才有;小程序与公众号若绑定了同一开放平台,
|
||||
// 可用它把两端身份关联起来,这里透传但不作为必填
|
||||
data.put("unionid", json.getString("unionid"));
|
||||
return success("获取成功", data);
|
||||
} catch (Exception e) {
|
||||
log.warn("HjcWechat: 小程序登录异常", e);
|
||||
return fail("微信登录失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "生成微信 JS-SDK 签名(用于 wx.config)")
|
||||
@GetMapping("/jsapi-sign")
|
||||
public ApiResult<?> jsapiSign(@RequestParam(value = "url", required = false) String url) {
|
||||
@@ -187,8 +380,101 @@ public class HjcWechatController extends BaseController {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取小程序配置。
|
||||
*
|
||||
* <p>用 {@link HjcAuthProperties} 的租户 ID,而不是请求头里的 tenantId:小程序登录发生在业务请求
|
||||
* 之前,请求头里的租户是客户端可改的,而小程序 appId 只应对应汇吉采自己的那份配置。</p>
|
||||
*
|
||||
* <p><b>为什么要依次试三个来源</b>:这套工程里小程序配置存在三套互不一致的键,
|
||||
* 只认其中任意一个都会出现「后台明明配了、接口却说没配」:</p>
|
||||
* <ol>
|
||||
* <li>{@code mp-weixin:{tenant}} —— {@code WxLoginController} 的读法;</li>
|
||||
* <li>{@code setting:mp-weixin:{tenant}} —— {@code SettingController} 更新设置时
|
||||
* <b>实际写入</b>的键(键名由 {@code "setting:" + key + ":" + tenant} 拼成);</li>
|
||||
* <li>{@code gxwebsoft_core.sys_setting} —— 真正的落库位置。后台「批量保存」
|
||||
* ({@code POST /system/setting/batch})<b>只写库、完全不写缓存</b>,
|
||||
* 所以不走这一步的话,用后台配好的小程序永远登录不了。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>跨库直读用原生 JDBC,既不依赖 common 层那份带坏 JOIN 的旧 mapper(见 ADR-0006),
|
||||
* 也不受 MyBatis-Plus 多租户拦截器影响。</p>
|
||||
*/
|
||||
private WxMpConfig loadMpConfig() {
|
||||
Integer tenantId = hjcAuthProperties.getTenantId();
|
||||
String[] redisKeys = {
|
||||
MP_SETTING_KEY_PREFIX + tenantId,
|
||||
SETTING_KEY_PREFIX + ":" + MP_WEIXIN_SETTING_KEY + ":" + tenantId,
|
||||
};
|
||||
for (String key : redisKeys) {
|
||||
// 缓存读失败不能当成「未配置」:Redis 一次抖动(实测见过 Connection reset)
|
||||
// 就会让 mp-appid/mp-login 整体失败,而数据库里本来就有一份可用配置。
|
||||
// 这里吞掉缓存异常,继续往下走到数据库回源。
|
||||
try {
|
||||
WxMpConfig cfg = parseMpConfig(stringRedisTemplate.opsForValue().get(key));
|
||||
if (cfg != null) {
|
||||
return cfg;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("HjcWechat: 读取小程序配置缓存失败,转为回源数据库 key={}", key, e);
|
||||
}
|
||||
}
|
||||
return parseMpConfig(queryMpSettingFromDb(tenantId));
|
||||
}
|
||||
|
||||
/** 解析小程序配置 JSON({@code {"appId":..,"appSecret":..}}),字段不全视为未配置 */
|
||||
private WxMpConfig parseMpConfig(String raw) {
|
||||
if (raw == null || raw.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JSONObject json = JSONObject.parseObject(raw);
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
WxMpConfig cfg = new WxMpConfig();
|
||||
cfg.appId = json.getString("appId");
|
||||
cfg.appSecret = json.getString("appSecret");
|
||||
if (cfg.appId == null || cfg.appSecret == null) {
|
||||
return null;
|
||||
}
|
||||
return cfg;
|
||||
} catch (Exception e) {
|
||||
log.warn("HjcWechat: 小程序配置解析失败", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨库回源读 {@code gxwebsoft_core.sys_setting} 里的小程序配置。
|
||||
*
|
||||
* <p>只读、失败不抛(返回 null 让上层给「未配置」的提示),避免把配置读取的异常
|
||||
* 变成小程序登录的整体不可用。</p>
|
||||
*/
|
||||
private String queryMpSettingFromDb(Integer tenantId) {
|
||||
String sql = "SELECT content FROM gxwebsoft_core.sys_setting WHERE setting_key = ? AND tenant_id = ? LIMIT 1";
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setString(1, MP_WEIXIN_SETTING_KEY);
|
||||
ps.setInt(2, tenantId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return rs.getString(1);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("HjcWechat: 回源查询小程序配置失败 tenantId={}", tenantId, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static class WxOfficialConfig {
|
||||
String appId;
|
||||
String appSecret;
|
||||
}
|
||||
|
||||
private static class WxMpConfig {
|
||||
String appId;
|
||||
String appSecret;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.gxwebsoft.hjc.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -45,6 +46,14 @@ public class CreatePurchaseDetails {
|
||||
@Schema(description = "状态:PAID/REFUNDED")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "退款时间,yyyy-MM-dd HH:mm:ss(仅 status=REFUNDED 时推送)")
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private String refundTime;
|
||||
|
||||
@Schema(description = "退款原因(仅 status=REFUNDED 时推送)")
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private String refundReason;
|
||||
|
||||
@Schema(description = "开票状态:NONE/APPLIED/ISSUED")
|
||||
private String invoiceStatus;
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.gxwebsoft.hjc.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 买家端轮播图(只读视图对象)
|
||||
*
|
||||
* <p>数据来自 CMS 的「轮播组 + 轮播项」(cms_banner_group / cms_banner_item),
|
||||
* 已按「启用分组 + 未删除明细 + 生效时间窗口」过滤并扁平化,供 H5 首页直接渲染。
|
||||
* 本对象只读,不对应任何 hjc 表。</p>
|
||||
*/
|
||||
@Data
|
||||
@Schema(name = "HjcBannerVo", description = "买家端轮播图")
|
||||
public class HjcBannerVo implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "轮播项ID")
|
||||
private Integer itemId;
|
||||
|
||||
@Schema(description = "所属轮播组ID")
|
||||
private Integer groupId;
|
||||
|
||||
@Schema(description = "标题(轮播项未填时取组标题)")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "副标题")
|
||||
private String subtitle;
|
||||
|
||||
@Schema(description = "图片地址")
|
||||
private String image;
|
||||
|
||||
@Schema(description = "跳转类型: 0无 1外链 2文章 3产品")
|
||||
private Integer linkType;
|
||||
|
||||
@Schema(description = "跳转地址(外链)")
|
||||
private String linkUrl;
|
||||
|
||||
@Schema(description = "跳转目标ID(文章/产品)")
|
||||
private Integer linkTargetId;
|
||||
|
||||
@Schema(description = "排序(组内,越小越靠前)")
|
||||
private Integer sortNumber;
|
||||
}
|
||||
@@ -74,6 +74,13 @@ public class HjcOrder implements Serializable {
|
||||
@Schema(description = "支付状态:0待支付 1支付成功 2支付失败 3已退款")
|
||||
private Integer payStatus;
|
||||
|
||||
@Schema(description = "退款时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime refundTime;
|
||||
|
||||
@Schema(description = "退款原因")
|
||||
private String refundReason;
|
||||
|
||||
@Schema(description = "订单状态:0待支付 1已完成 2已取消")
|
||||
private Integer orderStatus;
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.gxwebsoft.hjc.mapper;
|
||||
|
||||
import com.gxwebsoft.hjc.dto.HjcBannerVo;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 买家端轮播图查询(只读 CMS 轮播组表)
|
||||
*
|
||||
* <p>不继承 BaseMapper:本 Mapper 只做跨模块只读查询,不写任何表。
|
||||
* 租户隔离由 MyBatis-Plus 租户拦截器按请求头 tenantId 自动附加。</p>
|
||||
*/
|
||||
public interface HjcBannerMapper {
|
||||
|
||||
/**
|
||||
* 查询启用中的轮播项(扁平化,已过滤停用/已删除/不在生效时间窗口内的数据)
|
||||
*
|
||||
* @param position 展示位置标识(如 home_slider),为空时不过滤位置
|
||||
*/
|
||||
List<HjcBannerVo> selectEnabledList(@Param("position") String position);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.gxwebsoft.hjc.mapper.HjcBannerMapper">
|
||||
|
||||
<!--
|
||||
启用中的轮播项(只读):
|
||||
- 只取「启用分组(g.status=1) + 未删除分组/明细 + 生效时间窗口内」的数据;
|
||||
- 生效窗口用数据库 NOW() 比较,避免 JVM 与 DB 时区不一致导致误判(与 CMS 后台配置口径一致);
|
||||
- 无图的明细直接剔除,前端不会拿到空白轮播位;
|
||||
- 组内按 sort_number 升序,与其他模块共用一份排序规则。
|
||||
租户隔离由 MyBatis-Plus 租户拦截器按请求头 tenantId 自动附加到两张表。
|
||||
-->
|
||||
<select id="selectEnabledList" resultType="com.gxwebsoft.hjc.dto.HjcBannerVo">
|
||||
SELECT i.item_id,
|
||||
i.group_id,
|
||||
COALESCE(NULLIF(i.title, ''), g.title) AS title,
|
||||
i.subtitle,
|
||||
i.image,
|
||||
i.link_type,
|
||||
i.link_url,
|
||||
i.link_target_id,
|
||||
i.sort_number
|
||||
FROM cms_banner_item i
|
||||
INNER JOIN cms_banner_group g ON g.group_id = i.group_id
|
||||
WHERE i.deleted = 0
|
||||
AND i.image IS NOT NULL
|
||||
AND i.image <> ''
|
||||
AND g.deleted = 0
|
||||
AND g.status = 1
|
||||
AND (g.start_time IS NULL OR g.start_time <= NOW())
|
||||
AND (g.end_time IS NULL OR g.end_time >= NOW())
|
||||
<if test="position != null and position != ''">
|
||||
AND g.position = #{position}
|
||||
</if>
|
||||
ORDER BY g.sort_number ASC, i.sort_number ASC, i.item_id ASC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.gxwebsoft.hjc.service;
|
||||
|
||||
import com.gxwebsoft.hjc.dto.HjcBannerVo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 买家端轮播图服务(只读)
|
||||
*/
|
||||
public interface HjcBannerService {
|
||||
|
||||
/** 首页轮播的默认展示位置标识 */
|
||||
String POSITION_HOME_SLIDER = "home_slider";
|
||||
|
||||
/**
|
||||
* 查询启用中的轮播图
|
||||
*
|
||||
* @param position 展示位置标识(如 home_slider);为空时取首页轮播位置
|
||||
*/
|
||||
List<HjcBannerVo> listEnabled(String position);
|
||||
}
|
||||
@@ -21,6 +21,14 @@ public interface HjcBizService {
|
||||
*/
|
||||
HjcOrder markPaid(String orderNo);
|
||||
|
||||
/**
|
||||
* 退款:置已退款并记录退款时间(当前时间)/退款原因(幂等),同时推送 REFUNDED 状态到一站式
|
||||
*
|
||||
* @param orderNo 订单号
|
||||
* @param refundReason 退款原因,为空时用默认原因
|
||||
*/
|
||||
HjcOrder refund(String orderNo, String refundReason);
|
||||
|
||||
/**
|
||||
* 重试失败的推送(幂等)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.gxwebsoft.hjc.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.gxwebsoft.hjc.dto.HjcBannerVo;
|
||||
import com.gxwebsoft.hjc.mapper.HjcBannerMapper;
|
||||
import com.gxwebsoft.hjc.service.HjcBannerService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 买家端轮播图服务实现(只读 CMS 轮播组)
|
||||
*/
|
||||
@Service
|
||||
public class HjcBannerServiceImpl implements HjcBannerService {
|
||||
|
||||
@Resource
|
||||
private HjcBannerMapper hjcBannerMapper;
|
||||
|
||||
@Override
|
||||
public List<HjcBannerVo> listEnabled(String position) {
|
||||
return hjcBannerMapper.selectEnabledList(
|
||||
StrUtil.blankToDefault(position, POSITION_HOME_SLIDER));
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ public class HjcBizServiceImpl implements HjcBizService {
|
||||
private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final String STATUS_PAID = "PAID";
|
||||
private static final String STATUS_REFUNDED = "REFUNDED";
|
||||
/** 退款原因为空时的兜底值:一站式要求 REFUNDED 必带退款原因,不能推空串 */
|
||||
private static final String DEFAULT_REFUND_REASON = "管理员操作退款";
|
||||
/** 退款原因入库/推送长度上限(hjc_order.refund_reason) */
|
||||
private static final int REFUND_REASON_MAX = 255;
|
||||
|
||||
@Value("${hjc.one-stop.base-url:}")
|
||||
private String oneStopBaseUrl;
|
||||
@@ -146,6 +150,26 @@ public class HjcBizServiceImpl implements HjcBizService {
|
||||
return order;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public HjcOrder refund(String orderNo, String refundReason) {
|
||||
HjcOrder order = hjcOrderService.getByOrderNo(orderNo);
|
||||
if (order == null) {
|
||||
throw new RuntimeException("订单不存在");
|
||||
}
|
||||
if (order.getPayStatus() != null && order.getPayStatus() == 3) {
|
||||
return order;
|
||||
}
|
||||
order.setPayStatus(3);
|
||||
// 退款时间/原因必须落库:推送失败重试时按订单重建报文,否则重试会丢掉这两个字段
|
||||
order.setRefundTime(LocalDateTime.now());
|
||||
order.setRefundReason(StrUtil.isBlank(refundReason) ? DEFAULT_REFUND_REASON : limitStr(refundReason.trim(), REFUND_REASON_MAX));
|
||||
hjcOrderService.updateById(order);
|
||||
// 退款后触发一站式推送(status=REFUNDED + 退款时间/原因)
|
||||
pushOrderToOneStop(order);
|
||||
return order;
|
||||
}
|
||||
|
||||
private void doPush(HjcOrder order, HjcOrderPushLog logEntity) {
|
||||
try {
|
||||
CreatePurchaseDetails body = buildCreatePurchaseDetails(order);
|
||||
@@ -204,7 +228,14 @@ public class HjcBizServiceImpl implements HjcBizService {
|
||||
body.setTotalAmount(order.getTotalAmount());
|
||||
body.setPaidAt(order.getPayTime() == null ? null : order.getPayTime().format(DT_FMT));
|
||||
body.setPayMethod(order.getPayMethod());
|
||||
body.setStatus(order.getPayStatus() != null && order.getPayStatus() == 3 ? STATUS_REFUNDED : STATUS_PAID);
|
||||
boolean refunded = order.getPayStatus() != null && order.getPayStatus() == 3;
|
||||
body.setStatus(refunded ? STATUS_REFUNDED : STATUS_PAID);
|
||||
// 一站式要求退款推送必带退款时间与退款原因(非退款单不推这两个字段)
|
||||
if (refunded) {
|
||||
LocalDateTime refundTime = order.getRefundTime() != null ? order.getRefundTime() : order.getUpdateTime();
|
||||
body.setRefundTime(refundTime == null ? null : refundTime.format(DT_FMT));
|
||||
body.setRefundReason(StrUtil.isBlank(order.getRefundReason()) ? DEFAULT_REFUND_REASON : order.getRefundReason());
|
||||
}
|
||||
body.setInvoiceStatus(invoiceStatus(order.getInvoiceStatus()));
|
||||
|
||||
CreatePurchaseDetails.Buyer buyer = new CreatePurchaseDetails.Buyer();
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.gxwebsoft.hjc.util;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 汇吉采微信接入自检的判定逻辑。
|
||||
*
|
||||
* <p><b>为什么单独抽出来</b>:这段逻辑的价值在于「配置缺哪一项」的判定与文案,而它天然是纯函数。
|
||||
* 抽成不依赖 Spring 的静态方法后可以直接单测——微信接入的排查成本很高(四套配置、三套 Redis 键、
|
||||
* 跨库 sys_payment、以及「支付 appId 必须与小程序 appId 同值」这条隐性约束),
|
||||
* 判定错了会把排查方向带偏,所以它值得有测试。</p>
|
||||
*/
|
||||
public final class HjcWechatReadinessUtil {
|
||||
|
||||
private HjcWechatReadinessUtil() {
|
||||
}
|
||||
|
||||
/** 追加一条自检项;返回该项是否通过,便于调用方累积总结果 */
|
||||
public static boolean addCheck(List<Map<String, Object>> checks, String item, boolean pass,
|
||||
String why, String detail) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("item", item);
|
||||
row.put("pass", pass);
|
||||
row.put("why", why);
|
||||
row.put("detail", detail);
|
||||
checks.add(row);
|
||||
return pass;
|
||||
}
|
||||
|
||||
public static boolean notBlank(String s) {
|
||||
return s != null && !s.trim().isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打码:只留前 6 后 4。
|
||||
*
|
||||
* <p>自检结果会回给前端,即便调用者是管理员,也没有必要把完整 appId/商户号回显;
|
||||
* 而 appSecret 这类密钥<b>永不</b>进入自检输出。</p>
|
||||
*/
|
||||
public static String mask(String v) {
|
||||
if (v == null) {
|
||||
return null;
|
||||
}
|
||||
String s = v.trim();
|
||||
return s.length() <= 10 ? s : s.substring(0, 6) + "…" + s.substring(s.length() - 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付 appId 是否与小程序 appId 同值。
|
||||
*
|
||||
* <p>这是本方案里最容易配错、且报错最难懂的一条:微信要求 JSAPI 的 openid 与
|
||||
* 「商户号绑定的 appid」同源,而本方案的 openid 来自小程序。两者不同值时,
|
||||
* 下单会以 {@code APPID_MCHID_NOT_MATCH}(或 openid 与 appid 不匹配)失败,
|
||||
* 表面看却像「商户号没绑定」。</p>
|
||||
*
|
||||
* <p>任一侧为空时返回 {@code false}——此时真正的问题在别处(配置缺失),
|
||||
* 由对应的单项检查去报,这里不应给出「一致」的假结论。</p>
|
||||
*/
|
||||
public static boolean sameAppId(String payAppId, String mpAppId) {
|
||||
return notBlank(payAppId) && notBlank(mpAppId)
|
||||
&& payAppId.trim().equalsIgnoreCase(mpAppId.trim());
|
||||
}
|
||||
|
||||
/** 构造自检汇总:ready 为各单项的与,并给出统一说明 */
|
||||
public static Map<String, Object> summary(Integer tenantId, boolean ready,
|
||||
List<Map<String, Object>> checks) {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("tenantId", tenantId);
|
||||
data.put("ready", ready);
|
||||
data.put("checks", checks);
|
||||
data.put("note", "probe=true 时会实调微信验证 appSecret(需外网),默认只做本地检查");
|
||||
return data;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user