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:
2026-09-14 19:01:47 +08:00
parent b2b1a1bdf9
commit 2cd60270b9
21 changed files with 1124 additions and 16 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ _Avoid_: 项目(单用), 标书(单用), tender
**企业账号 (Enterprise Account)**:
买家账号,用企业名称+密码登录,须通过资质审核才能购买标书。
_Avoid_: 用户, 会员, buyer
_Avoid_: 用户, 会员, buyer, hjc_user
**资质认证 (Qualification)**:
企业注册后提交、由平台审核的资质材料,状态为 待审核/已通过/已驳回(含驳回原因)。未通过不可下单。
+36 -1
View File
@@ -112,7 +112,10 @@ POST {一站式BaseUrl}/api/biz/createPurchaseDetails
### 3.2 触发时机
- **支付成功后**(官方网 `PUT /api/hjc/order/mark-paid` 置已付)自动推送,请求状态 `status=PAID`
- **退款时**(官方网 `POST /api/hjc/order/refund` 置已退款)推送,请求状态 `status=REFUNDED`
- **退款时**(官方网 `POST /api/hjc/order/refund` 置已退款)推送,请求状态 `status=REFUNDED`**必须**携带 `refundTime`(退款时间)与 `refundReason`(退款原因)
- `refundTime`:官方网记录退款动作发生时间(服务器时间),落库 `hjc_order.refund_time`
- `refundReason`:后台退款弹窗填写(≤200 字),落库 `hjc_order.refund_reason`;未填写时取默认「管理员操作退款」。
- 推送失败进重试队列后,重试报文的退款时间/原因按订单落库值重建,不会丢失。
### 3.3 请求头
@@ -144,6 +147,8 @@ POST {一站式BaseUrl}/api/biz/createPurchaseDetails
| `paidAt` | string | 支付时间 `yyyy-MM-dd HH:mm:ss` |
| `payMethod` | string | `WECHAT_NATIVE` / `ALIPAY` |
| `status` | string | `PAID`(已支付)/ `REFUNDED`(已退款) |
| `refundTime` | string | **退款时必传**,退款时间 `yyyy-MM-dd HH:mm:ss``status=REFUNDED` 时推送,其余状态不出现在报文中) |
| `refundReason` | string | **退款时必传**,退款原因(≤255 字符;后台未填写时取默认「管理员操作退款」) |
| `invoiceStatus` | string | `NONE` / `APPLIED` / `ISSUED` |
请求体示例:
@@ -171,6 +176,33 @@ POST {一站式BaseUrl}/api/biz/createPurchaseDetails
}
```
退款推送请求体示例(`status=REFUNDED`,多出 `refundTime`/`refundReason`):
```json
{
"idempotencyKey": "HJC_ORD_HJC2026080101ABCD",
"orderNo": "HJC2026080101ABCD",
"projectNo": "ZB-2026-0001",
"projectName": "xxx采购项目",
"tenderPrice": 500.00,
"quantity": 1,
"totalAmount": 500.00,
"buyer": {
"enterpriseName": "某某科技有限公司",
"creditCode": "91110XXXX",
"contactName": "张三",
"contactPhone": "13800000000",
"contactEmail": "a@b.com"
},
"paidAt": "2026-08-01 10:30:00",
"payMethod": "WECHAT_NATIVE",
"status": "REFUNDED",
"refundTime": "2026-08-03 09:15:00",
"refundReason": "项目终止,客户申请退款",
"invoiceStatus": "NONE"
}
```
### 3.5 响应约定
- 一站式按自身 `API` 返回;官方网侧把 `HTTP 2xx` 视为成功,否则记入推送日志(`hjc_order_push_log`)待重试。
@@ -201,6 +233,8 @@ POST {一站式BaseUrl}/api/biz/createPurchaseDetails
| `fileList` | 公告附件 | `bulletin_file_list` |
| `needSellTender` | 是否卖标书 | `need_sell` |
| `sellingMethod` | 售卖方式 | `selling_method` |
| `refundTime` | 退款时间(出向) | `hjc_order.refund_time` |
| `refundReason` | 退款原因(出向) | `hjc_order.refund_reason` |
---
@@ -223,3 +257,4 @@ POST {一站式BaseUrl}/api/biz/createPurchaseDetails
2. `timestamp` 格式、时区、±10min 窗口是否认可。
3. `createPurchaseDetails` 字段名是否需与一站式现网入参完全一致(现按本表定义);如需调整字段名请给出一站式入参清单。
4. 入向:一站式调用官方网 `/api/hjc/push/project` 时是否也按同一套 `appKey/timestamp/sign` 鉴权(本表默认对称鉴权)。
5. 退款字段名 `refundTime` / `refundReason` 与一站式现网入参是否一致;格式默认 `yyyy-MM-dd HH:mm:ss`,如一站式要求其他命名或时间格式请同步调整 `CreatePurchaseDetails`
@@ -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网页授权取 openidJS-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 换 openidPOST /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_tokenJS-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 &lt;&gt; ''
AND g.deleted = 0
AND g.status = 1
AND (g.start_time IS NULL OR g.start_time &lt;= NOW())
AND (g.end_time IS NULL OR g.end_time &gt;= 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;
}
}
+2
View File
@@ -100,6 +100,8 @@ CREATE TABLE `hjc_order` (
`pay_method` varchar(32) DEFAULT NULL COMMENT '支付方式:WECHAT_NATIVE/ALIPAY',
`pay_time` datetime DEFAULT NULL COMMENT '支付时间',
`pay_status` tinyint(4) DEFAULT 0 COMMENT '支付状态:0待支付 1支付成功 2支付失败 3已退款',
`refund_time` datetime DEFAULT NULL COMMENT '退款时间',
`refund_reason` varchar(255) DEFAULT NULL COMMENT '退款原因',
`order_status` tinyint(4) DEFAULT 0 COMMENT '订单状态:0待支付 1已完成 2已取消',
`invoice_status` tinyint(4) DEFAULT 0 COMMENT '开票状态:0未开票 1已申请 2已开票',
`tenant_id` int(11) DEFAULT NULL COMMENT '租户ID',
@@ -0,0 +1,31 @@
-- 汇吉采:标书订单新增「退款时间/退款原因」字段(后台退款时记录,并随 REFUNDED 状态推送一站式平台)
--
-- 幂等:MySQL 8 不支持 `ADD COLUMN IF NOT EXISTS`,故按 information_schema 判断后再拼 DDL 执行。
-- 这样本脚本在「已加过列」的环境上重复执行不会报 1060 Duplicate column name
-- 可安全地作为发布步骤对每个环境跑一遍(各环境建表时间不同,缺列情况不一致)。
--
-- 只增列、不删不改,且仅作用于 hjc 自己的 hjc_order 表,对共用本库的其他项目无影响。
-- refund_time:缺失则添加
SET @ddl := (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `hjc_order` ADD COLUMN `refund_time` datetime DEFAULT NULL COMMENT ''退款时间'' AFTER `pay_status`',
'DO 0')
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'hjc_order' AND column_name = 'refund_time'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- refund_reason:缺失则添加(排在 refund_time 之后,与 hjc_init.sql 的列序一致)
SET @ddl := (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `hjc_order` ADD COLUMN `refund_reason` varchar(255) DEFAULT NULL COMMENT ''退款原因'' AFTER `refund_time`',
'DO 0')
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'hjc_order' AND column_name = 'refund_reason'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,79 @@
package com.gxwebsoft.hjc;
import com.gxwebsoft.common.core.config.MybatisPlusConfig;
import com.gxwebsoft.common.core.utils.RedisUtil;
import com.gxwebsoft.hjc.controller.HjcBannerController;
import com.gxwebsoft.hjc.service.impl.HjcBannerServiceImpl;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 买家端轮播图接口 {@code GET /api/hjc/banner/list} 测试
*
* <p>只装配「数据源 + MyBatis-Plus(含租户拦截器)+ 轮播 Controller/Service」的最小上下文,
* 不启动整个应用(避免触发 socketio / 定时任务等副作用),因此不能用 {@code @SpringBootTest} 的默认主类扫描。</p>
*
* <p>依赖 dev 环境数据库与 Redis(与仓库其它集成测试一致)。</p>
*/
@SpringBootTest(classes = HjcBannerApiTest.MinimalApp.class)
@AutoConfigureMockMvc
@ActiveProfiles("dev")
class HjcBannerApiTest {
/** 最小装配:只加载轮播接口链路所需的 Bean */
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
@MapperScan("com.gxwebsoft.hjc.mapper")
@Import({MybatisPlusConfig.class, RedisUtil.class, HjcBannerServiceImpl.class, HjcBannerController.class})
static class MinimalApp {
}
@Autowired
private MockMvc mockMvc;
@Test
@DisplayName("按租户返回启用中的首页轮播图,字段结构完整")
void listReturnsEnabledHomeSliderBanners() throws Exception {
mockMvc.perform(get("/api/hjc/banner/list").header("tenantId", "10626"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").isArray())
// 有配置时每条都必须是「可渲染」的:有 itemId、有图片地址
.andExpect(jsonPath("$.data[?(@.itemId == null)]").isEmpty())
.andExpect(jsonPath("$.data[?(@.image == null || @.image == '')]").isEmpty());
}
@Test
@DisplayName("未配置轮播的租户返回空数组(不报错、不串租户)")
void listReturnsEmptyForTenantWithoutBanner() throws Exception {
mockMvc.perform(get("/api/hjc/banner/list").header("tenantId", "999999"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").isEmpty());
}
@Test
@DisplayName("position 参数生效:非首页位置不应返回首页轮播")
void listFiltersByPosition() throws Exception {
mockMvc.perform(get("/api/hjc/banner/list")
.header("tenantId", "10626")
.param("position", "inner_page"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").isEmpty());
}
}
@@ -0,0 +1,168 @@
package com.gxwebsoft.hjc.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gxwebsoft.hjc.entity.HjcOrder;
import com.gxwebsoft.hjc.entity.HjcOrderPushLog;
import com.gxwebsoft.hjc.service.HjcBidProjectService;
import com.gxwebsoft.hjc.service.HjcEnterpriseService;
import com.gxwebsoft.hjc.service.HjcOrderPushLogService;
import com.gxwebsoft.hjc.service.HjcOrderService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 出向推送报文(createPurchaseDetails)字段测试:退款推送必须带「退款时间 + 退款原因」。
*
* <p>不启动 Spring 上下文、不连数据库:{@code hjc.one-stop.base-url} 未注入(为空)时
* {@code doPush} 只落推送日志、不发 HTTP,因此可从推送日志的 payload 断言报文。</p>
*/
@ExtendWith(MockitoExtension.class)
class HjcBizServiceImplPushPayloadTest {
private static final ObjectMapper JSON = new ObjectMapper();
@Mock
private HjcBidProjectService hjcBidProjectService;
@Mock
private HjcEnterpriseService hjcEnterpriseService;
@Mock
private HjcOrderService hjcOrderService;
@Mock
private HjcOrderPushLogService hjcOrderPushLogService;
@Mock
private RestTemplate restTemplate;
/** 真实 ObjectMapper(序列化报文是断言目标,不能 mock 掉) */
@Spy
private ObjectMapper objectMapper = new ObjectMapper();
@InjectMocks
private HjcBizServiceImpl hjcBizService;
private HjcOrder paidOrder() {
HjcOrder order = new HjcOrder();
order.setId(1);
order.setOrderNo("HJC2026080101ABCD");
order.setProjectNo("ZB-2026-0001");
order.setProjectName("xxx采购项目");
order.setUnitPrice(new BigDecimal("500.00"));
order.setQuantity(1);
order.setTotalAmount(new BigDecimal("500.00"));
order.setPayMethod("WECHAT_NATIVE");
order.setPayStatus(1);
order.setPayTime(LocalDateTime.of(2026, 8, 1, 10, 30, 0));
order.setEnterpriseName("某某科技有限公司");
return order;
}
/** 取本次推送写进推送日志的报文(base-url 为空 → 只写日志不发送) */
private JsonNode capturedPayload() throws Exception {
ArgumentCaptor<HjcOrderPushLog> captor = ArgumentCaptor.forClass(HjcOrderPushLog.class);
verify(hjcOrderPushLogService).updateById(captor.capture());
String payload = captor.getValue().getPayload();
assertNotNull(payload, "推送报文不应为空");
return JSON.readTree(payload);
}
@Test
@DisplayName("退款:落库退款时间与退款原因,推送报文 status=REFUNDED 且带 refundTime/refundReason")
void refundPushesRefundTimeAndReason() throws Exception {
HjcOrder order = paidOrder();
when(hjcOrderService.getByOrderNo(order.getOrderNo())).thenReturn(order);
LocalDateTime before = LocalDateTime.now();
HjcOrder refunded = hjcBizService.refund(order.getOrderNo(), "项目终止,客户申请退款");
assertEquals(3, refunded.getPayStatus());
assertEquals("项目终止,客户申请退款", refunded.getRefundReason());
assertNotNull(refunded.getRefundTime(), "退款时间必须落库");
assertFalse(refunded.getRefundTime().isBefore(before.minusSeconds(1)), "退款时间应取退款动作发生时刻");
verify(hjcOrderService).updateById(order);
JsonNode payload = capturedPayload();
assertEquals("REFUNDED", payload.path("status").asText());
assertEquals("2026-08-01 10:30:00", payload.path("paidAt").asText());
assertTrue(payload.hasNonNull("refundTime"), "退款推送必须带退款时间");
assertEquals(refunded.getRefundTime().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
payload.path("refundTime").asText());
assertEquals("项目终止,客户申请退款", payload.path("refundReason").asText());
}
@Test
@DisplayName("退款:未填退款原因时兜底默认原因,不会推送空字段")
void refundWithoutReasonFallsBackToDefault() throws Exception {
HjcOrder order = paidOrder();
when(hjcOrderService.getByOrderNo(order.getOrderNo())).thenReturn(order);
hjcBizService.refund(order.getOrderNo(), " ");
assertEquals("管理员操作退款", order.getRefundReason());
JsonNode payload = capturedPayload();
assertEquals("REFUNDED", payload.path("status").asText());
assertFalse(payload.path("refundReason").asText().isEmpty(), "退款原因不能为空");
assertTrue(payload.hasNonNull("refundTime"));
}
@Test
@DisplayName("退款:超长退款原因按字段上限截断,避免入库/推送失败")
void refundReasonIsTruncated() {
HjcOrder order = paidOrder();
when(hjcOrderService.getByOrderNo(order.getOrderNo())).thenReturn(order);
hjcBizService.refund(order.getOrderNo(), "x".repeat(300));
assertEquals(255, order.getRefundReason().length());
}
@Test
@DisplayName("退款:已退款订单幂等,不重复落库、不重复推送")
void refundIsIdempotent() {
HjcOrder order = paidOrder();
order.setPayStatus(3);
when(hjcOrderService.getByOrderNo(order.getOrderNo())).thenReturn(order);
hjcBizService.refund(order.getOrderNo(), "重复退款");
verify(hjcOrderService, never()).updateById(any(HjcOrder.class));
verify(hjcOrderPushLogService, never()).save(any(HjcOrderPushLog.class));
}
@Test
@DisplayName("支付:推送报文 status=PAID 且不含退款时间/退款原因")
void paidPushOmitsRefundFields() throws Exception {
hjcBizService.pushOrderToOneStop(paidOrder());
JsonNode payload = capturedPayload();
assertEquals("PAID", payload.path("status").asText());
assertFalse(payload.has("refundTime"), "非退款单不应推退款时间");
assertFalse(payload.has("refundReason"), "非退款单不应推退款原因");
}
@Test
@DisplayName("支付:缺订单号时直接返回,不写推送日志")
void pushWithoutOrderNoIsIgnored() {
hjcBizService.pushOrderToOneStop(new HjcOrder());
verify(hjcOrderPushLogService, never()).save(any(HjcOrderPushLog.class));
verify(hjcOrderPushLogService, never()).updateById(any(HjcOrderPushLog.class));
}
}
@@ -0,0 +1,86 @@
package com.gxwebsoft.hjc.util;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 微信接入自检判定逻辑的单测。
*
* <p>这段逻辑决定「排查方向」:把「配置缺失」误判成「配置就绪」会让人去翻微信后台,
* 把「appId 不一致」漏掉则会得到一个看起来像「商户号没绑定」的报错。
* 所以这几条判定都要钉住。</p>
*/
class HjcWechatReadinessUtilTest {
@Test
void addCheckShouldRecordPassAndReturnIt() {
List<Map<String, Object>> checks = new ArrayList<>();
boolean pass = HjcWechatReadinessUtil.addCheck(checks, "小程序配置", true, "为什么", "明细");
boolean fail = HjcWechatReadinessUtil.addCheck(checks, "公众号配置", false, "为什么2", "明细2");
assertTrue(pass, "通过的检查项应返回 true,便于用 &= 累积总结果");
assertFalse(fail, "未通过的检查项应返回 false");
assertEquals(2, checks.size());
assertEquals("小程序配置", checks.get(0).get("item"));
assertEquals(Boolean.TRUE, checks.get(0).get("pass"));
assertEquals("明细", checks.get(0).get("detail"));
assertEquals(Boolean.FALSE, checks.get(1).get("pass"));
}
@Test
void notBlankShouldTreatWhitespaceAsMissing() {
assertFalse(HjcWechatReadinessUtil.notBlank(null));
assertFalse(HjcWechatReadinessUtil.notBlank(""));
// 配置项写成空格是最常见的「看着配了其实没配」
assertFalse(HjcWechatReadinessUtil.notBlank(" "));
assertTrue(HjcWechatReadinessUtil.notBlank("wx3c5fb12ce7bda153"));
}
@Test
void maskShouldHideMiddleButKeepEnds() {
assertEquals("wx3c5f…a153", HjcWechatReadinessUtil.mask("wx3c5fb12ce7bda153"));
// 短值原样返回,避免打码后无法辨认
assertEquals("abc", HjcWechatReadinessUtil.mask("abc"));
assertEquals("1234567890", HjcWechatReadinessUtil.mask("1234567890"));
assertNull(HjcWechatReadinessUtil.mask(null));
}
@Test
void sameAppIdShouldIgnoreCaseAndTrim() {
assertTrue(HjcWechatReadinessUtil.sameAppId("wx3c5fb12ce7bda153", "wx3c5fb12ce7bda153"));
assertTrue(HjcWechatReadinessUtil.sameAppId(" WX3C5FB12CE7BDA153 ", "wx3c5fb12ce7bda153"),
"appId 大小写与首尾空格不应造成误判");
assertFalse(HjcWechatReadinessUtil.sameAppId("wx5c60e74d6b6f997f", "wx3c5fb12ce7bda153"),
"不同 appId 必须判为不一致——这正是 APPID_MCHID_NOT_MATCH 的成因");
}
@Test
void sameAppIdShouldBeFalseWhenEitherSideMissing() {
// 任一侧为空时不能给「一致」的假结论:此时真正的问题是配置缺失,
// 由对应的单项检查去报,否则会掩盖掉真正的缺失项。
assertFalse(HjcWechatReadinessUtil.sameAppId(null, "wx3c5fb12ce7bda153"));
assertFalse(HjcWechatReadinessUtil.sameAppId("wx3c5fb12ce7bda153", null));
assertFalse(HjcWechatReadinessUtil.sameAppId(" ", "wx3c5fb12ce7bda153"));
}
@Test
void summaryShouldCarryReadyAndChecks() {
List<Map<String, Object>> checks = new ArrayList<>();
HjcWechatReadinessUtil.addCheck(checks, "小程序配置", false, "why", "detail");
Map<String, Object> data = HjcWechatReadinessUtil.summary(10626, false, checks);
assertEquals(10626, data.get("tenantId"));
assertEquals(Boolean.FALSE, data.get("ready"));
assertEquals(checks, data.get("checks"));
}
}