diff --git a/CONTEXT.md b/CONTEXT.md index e8971e9..dfac5ae 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -10,7 +10,7 @@ _Avoid_: 项目(单用), 标书(单用), tender **企业账号 (Enterprise Account)**: 买家账号,用企业名称+密码登录,须通过资质审核才能购买标书。 -_Avoid_: 用户, 会员, buyer +_Avoid_: 用户, 会员, buyer, hjc_user **资质认证 (Qualification)**: 企业注册后提交、由平台审核的资质材料,状态为 待审核/已通过/已驳回(含驳回原因)。未通过不可下单。 diff --git a/docs/一站式平台对接-接口文档.md b/docs/一站式平台对接-接口文档.md index 7834631..28993ca 100644 --- a/docs/一站式平台对接-接口文档.md +++ b/docs/一站式平台对接-接口文档.md @@ -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`。 diff --git a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java index 21da82e..de9f5ed 100644 --- a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java +++ b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java @@ -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() diff --git a/src/main/java/com/gxwebsoft/hjc/controller/HjcBannerController.java b/src/main/java/com/gxwebsoft/hjc/controller/HjcBannerController.java new file mode 100644 index 0000000..fc573e6 --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/controller/HjcBannerController.java @@ -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 端只读) + * + *

数据源为 CMS 后台维护的轮播组(cms_banner_group / cms_banner_item), + * 运营在管理后台配置图片、跳转与生效时间即可生效,无需改前端代码。 + * 仅提供 GET,未登录也可访问(用于首页首屏)。

+ */ +@Tag(name = "汇吉采-首页轮播") +@RestController +@RequestMapping("/api/hjc/banner") +public class HjcBannerController extends BaseController { + + @Resource + private HjcBannerService hjcBannerService; + + @Operation(summary = "启用中的轮播图列表(C端)") + @GetMapping("/list") + public ApiResult> list( + @RequestParam(value = "position", required = false) String position) { + return success(hjcBannerService.listEnabled(position)); + } +} diff --git a/src/main/java/com/gxwebsoft/hjc/controller/HjcOrderController.java b/src/main/java/com/gxwebsoft/hjc/controller/HjcOrderController.java index 9f67cdd..20dab66 100644 --- a/src/main/java/com/gxwebsoft/hjc/controller/HjcOrderController.java +++ b/src/main/java/com/gxwebsoft/hjc/controller/HjcOrderController.java @@ -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 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)); + } + + /** + * 向微信查单,返回**微信侧**的真实支付状态;若微信已确认支付成功,则**顺带修复本地订单状态**。 + * + *

为什么需要这个接口:客户端在 JSAPI/requestPayment 失败后只能看到一句错误 + * (用户取消、密码错、还是其实已经付成功了,前端分不清)。而「已支付」是事实, + * 应当以微信侧为准去查,而不是由前端调 {@code /mark-paid} 自说自话。

+ * + *

为什么这里要写库(而不只是查):汇吉采的支付结果回写完全依赖前端—— + * 微信回调 {@code PaymentNotifyController} 只做验签与应答,不认识 hjc 订单; + * 订单置为已支付只有 {@code /mark-paid} 一条路。于是「用户付了钱但页面被关掉/断网」 + * 就会留下一笔已收款却仍显示待支付的订单。这里是唯一以微信侧为准的入口, + * 因此在确认 {@code SUCCESS} 时就地调 {@code markPaid} 补齐状态(幂等,且会触发一站式推送)。

+ * + *

安全性:查询前已做「必须登录 + 订单归属校验」,故不存在越权改他人订单状态的问题。

+ */ + @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 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) { diff --git a/src/main/java/com/gxwebsoft/hjc/controller/HjcWechatController.java b/src/main/java/com/gxwebsoft/hjc/controller/HjcWechatController.java index de4e33c..86d6ce5 100644 --- a/src/main/java/com/gxwebsoft/hjc/controller/HjcWechatController.java +++ b/src/main/java/com/gxwebsoft/hjc/controller/HjcWechatController.java @@ -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":"..."} + *

两套配置分别来自 Redis:

+ *
    + *
  • 公众号:{@code cache{tenantId}:setting:wx-official} → {@code {"appId","appSecret"}}
  • + *
  • 小程序:{@code mp-weixin:{tenantId}} → {@code {"appId","appSecret"}}(与平台通用小程序配置同一份)
  • + *
+ * + *

两套 openid 不可互用:小程序 openid 属于小程序 appid,公众号网页授权 openid 属于 + * 公众号 appid;微信支付 JSAPI 下单要求 openid 与商户号绑定的 appid 同源,故两条链路各自取各自的 + * openid,服务端不做换算。

* * @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> 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 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 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 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; } + /** + * 读取小程序配置。 + * + *

用 {@link HjcAuthProperties} 的租户 ID,而不是请求头里的 tenantId:小程序登录发生在业务请求 + * 之前,请求头里的租户是客户端可改的,而小程序 appId 只应对应汇吉采自己的那份配置。

+ * + *

为什么要依次试三个来源:这套工程里小程序配置存在三套互不一致的键, + * 只认其中任意一个都会出现「后台明明配了、接口却说没配」:

+ *
    + *
  1. {@code mp-weixin:{tenant}} —— {@code WxLoginController} 的读法;
  2. + *
  3. {@code setting:mp-weixin:{tenant}} —— {@code SettingController} 更新设置时 + * 实际写入的键(键名由 {@code "setting:" + key + ":" + tenant} 拼成);
  4. + *
  5. {@code gxwebsoft_core.sys_setting} —— 真正的落库位置。后台「批量保存」 + * ({@code POST /system/setting/batch})只写库、完全不写缓存, + * 所以不走这一步的话,用后台配好的小程序永远登录不了。
  6. + *
+ * + *

跨库直读用原生 JDBC,既不依赖 common 层那份带坏 JOIN 的旧 mapper(见 ADR-0006), + * 也不受 MyBatis-Plus 多租户拦截器影响。

+ */ + 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} 里的小程序配置。 + * + *

只读、失败不抛(返回 null 让上层给「未配置」的提示),避免把配置读取的异常 + * 变成小程序登录的整体不可用。

+ */ + 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; + } } diff --git a/src/main/java/com/gxwebsoft/hjc/dto/CreatePurchaseDetails.java b/src/main/java/com/gxwebsoft/hjc/dto/CreatePurchaseDetails.java index 6e18c0c..fdb8dea 100644 --- a/src/main/java/com/gxwebsoft/hjc/dto/CreatePurchaseDetails.java +++ b/src/main/java/com/gxwebsoft/hjc/dto/CreatePurchaseDetails.java @@ -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; diff --git a/src/main/java/com/gxwebsoft/hjc/dto/HjcBannerVo.java b/src/main/java/com/gxwebsoft/hjc/dto/HjcBannerVo.java new file mode 100644 index 0000000..0261b8e --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/dto/HjcBannerVo.java @@ -0,0 +1,46 @@ +package com.gxwebsoft.hjc.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 买家端轮播图(只读视图对象) + * + *

数据来自 CMS 的「轮播组 + 轮播项」(cms_banner_group / cms_banner_item), + * 已按「启用分组 + 未删除明细 + 生效时间窗口」过滤并扁平化,供 H5 首页直接渲染。 + * 本对象只读,不对应任何 hjc 表。

+ */ +@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; +} diff --git a/src/main/java/com/gxwebsoft/hjc/entity/HjcOrder.java b/src/main/java/com/gxwebsoft/hjc/entity/HjcOrder.java index 027be86..b0005e2 100644 --- a/src/main/java/com/gxwebsoft/hjc/entity/HjcOrder.java +++ b/src/main/java/com/gxwebsoft/hjc/entity/HjcOrder.java @@ -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; diff --git a/src/main/java/com/gxwebsoft/hjc/mapper/HjcBannerMapper.java b/src/main/java/com/gxwebsoft/hjc/mapper/HjcBannerMapper.java new file mode 100644 index 0000000..ba15e0d --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/mapper/HjcBannerMapper.java @@ -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 轮播组表) + * + *

不继承 BaseMapper:本 Mapper 只做跨模块只读查询,不写任何表。 + * 租户隔离由 MyBatis-Plus 租户拦截器按请求头 tenantId 自动附加。

+ */ +public interface HjcBannerMapper { + + /** + * 查询启用中的轮播项(扁平化,已过滤停用/已删除/不在生效时间窗口内的数据) + * + * @param position 展示位置标识(如 home_slider),为空时不过滤位置 + */ + List selectEnabledList(@Param("position") String position); +} diff --git a/src/main/java/com/gxwebsoft/hjc/mapper/xml/HjcBannerMapper.xml b/src/main/java/com/gxwebsoft/hjc/mapper/xml/HjcBannerMapper.xml new file mode 100644 index 0000000..26b7499 --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/mapper/xml/HjcBannerMapper.xml @@ -0,0 +1,38 @@ + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/hjc/service/HjcBannerService.java b/src/main/java/com/gxwebsoft/hjc/service/HjcBannerService.java new file mode 100644 index 0000000..916eb30 --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/service/HjcBannerService.java @@ -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 listEnabled(String position); +} diff --git a/src/main/java/com/gxwebsoft/hjc/service/HjcBizService.java b/src/main/java/com/gxwebsoft/hjc/service/HjcBizService.java index cf5d1d9..778961c 100644 --- a/src/main/java/com/gxwebsoft/hjc/service/HjcBizService.java +++ b/src/main/java/com/gxwebsoft/hjc/service/HjcBizService.java @@ -21,6 +21,14 @@ public interface HjcBizService { */ HjcOrder markPaid(String orderNo); + /** + * 退款:置已退款并记录退款时间(当前时间)/退款原因(幂等),同时推送 REFUNDED 状态到一站式 + * + * @param orderNo 订单号 + * @param refundReason 退款原因,为空时用默认原因 + */ + HjcOrder refund(String orderNo, String refundReason); + /** * 重试失败的推送(幂等) */ diff --git a/src/main/java/com/gxwebsoft/hjc/service/impl/HjcBannerServiceImpl.java b/src/main/java/com/gxwebsoft/hjc/service/impl/HjcBannerServiceImpl.java new file mode 100644 index 0000000..0f38c7e --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/service/impl/HjcBannerServiceImpl.java @@ -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 listEnabled(String position) { + return hjcBannerMapper.selectEnabledList( + StrUtil.blankToDefault(position, POSITION_HOME_SLIDER)); + } +} diff --git a/src/main/java/com/gxwebsoft/hjc/service/impl/HjcBizServiceImpl.java b/src/main/java/com/gxwebsoft/hjc/service/impl/HjcBizServiceImpl.java index 21a387a..9649b45 100644 --- a/src/main/java/com/gxwebsoft/hjc/service/impl/HjcBizServiceImpl.java +++ b/src/main/java/com/gxwebsoft/hjc/service/impl/HjcBizServiceImpl.java @@ -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(); diff --git a/src/main/java/com/gxwebsoft/hjc/util/HjcWechatReadinessUtil.java b/src/main/java/com/gxwebsoft/hjc/util/HjcWechatReadinessUtil.java new file mode 100644 index 0000000..64346d3 --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/util/HjcWechatReadinessUtil.java @@ -0,0 +1,76 @@ +package com.gxwebsoft.hjc.util; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 汇吉采微信接入自检的判定逻辑。 + * + *

为什么单独抽出来:这段逻辑的价值在于「配置缺哪一项」的判定与文案,而它天然是纯函数。 + * 抽成不依赖 Spring 的静态方法后可以直接单测——微信接入的排查成本很高(四套配置、三套 Redis 键、 + * 跨库 sys_payment、以及「支付 appId 必须与小程序 appId 同值」这条隐性约束), + * 判定错了会把排查方向带偏,所以它值得有测试。

+ */ +public final class HjcWechatReadinessUtil { + + private HjcWechatReadinessUtil() { + } + + /** 追加一条自检项;返回该项是否通过,便于调用方累积总结果 */ + public static boolean addCheck(List> checks, String item, boolean pass, + String why, String detail) { + Map 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。 + * + *

自检结果会回给前端,即便调用者是管理员,也没有必要把完整 appId/商户号回显; + * 而 appSecret 这类密钥永不进入自检输出。

+ */ + 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 同值。 + * + *

这是本方案里最容易配错、且报错最难懂的一条:微信要求 JSAPI 的 openid 与 + * 「商户号绑定的 appid」同源,而本方案的 openid 来自小程序。两者不同值时, + * 下单会以 {@code APPID_MCHID_NOT_MATCH}(或 openid 与 appid 不匹配)失败, + * 表面看却像「商户号没绑定」。

+ * + *

任一侧为空时返回 {@code false}——此时真正的问题在别处(配置缺失), + * 由对应的单项检查去报,这里不应给出「一致」的假结论。

+ */ + public static boolean sameAppId(String payAppId, String mpAppId) { + return notBlank(payAppId) && notBlank(mpAppId) + && payAppId.trim().equalsIgnoreCase(mpAppId.trim()); + } + + /** 构造自检汇总:ready 为各单项的与,并给出统一说明 */ + public static Map summary(Integer tenantId, boolean ready, + List> checks) { + Map data = new LinkedHashMap<>(); + data.put("tenantId", tenantId); + data.put("ready", ready); + data.put("checks", checks); + data.put("note", "probe=true 时会实调微信验证 appSecret(需外网),默认只做本地检查"); + return data; + } +} diff --git a/src/main/resources/sql/hjc_init.sql b/src/main/resources/sql/hjc_init.sql index 20cfbe9..bb254ea 100644 --- a/src/main/resources/sql/hjc_init.sql +++ b/src/main/resources/sql/hjc_init.sql @@ -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', diff --git a/src/main/resources/sql/hjc_order_add_refund.sql b/src/main/resources/sql/hjc_order_add_refund.sql new file mode 100644 index 0000000..d7ab978 --- /dev/null +++ b/src/main/resources/sql/hjc_order_add_refund.sql @@ -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; diff --git a/src/test/java/com/gxwebsoft/hjc/HjcBannerApiTest.java b/src/test/java/com/gxwebsoft/hjc/HjcBannerApiTest.java new file mode 100644 index 0000000..6453f25 --- /dev/null +++ b/src/test/java/com/gxwebsoft/hjc/HjcBannerApiTest.java @@ -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} 测试 + * + *

只装配「数据源 + MyBatis-Plus(含租户拦截器)+ 轮播 Controller/Service」的最小上下文, + * 不启动整个应用(避免触发 socketio / 定时任务等副作用),因此不能用 {@code @SpringBootTest} 的默认主类扫描。

+ * + *

依赖 dev 环境数据库与 Redis(与仓库其它集成测试一致)。

+ */ +@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()); + } +} diff --git a/src/test/java/com/gxwebsoft/hjc/service/impl/HjcBizServiceImplPushPayloadTest.java b/src/test/java/com/gxwebsoft/hjc/service/impl/HjcBizServiceImplPushPayloadTest.java new file mode 100644 index 0000000..8b940f0 --- /dev/null +++ b/src/test/java/com/gxwebsoft/hjc/service/impl/HjcBizServiceImplPushPayloadTest.java @@ -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)字段测试:退款推送必须带「退款时间 + 退款原因」。 + * + *

不启动 Spring 上下文、不连数据库:{@code hjc.one-stop.base-url} 未注入(为空)时 + * {@code doPush} 只落推送日志、不发 HTTP,因此可从推送日志的 payload 断言报文。

+ */ +@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 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)); + } +} diff --git a/src/test/java/com/gxwebsoft/hjc/util/HjcWechatReadinessUtilTest.java b/src/test/java/com/gxwebsoft/hjc/util/HjcWechatReadinessUtilTest.java new file mode 100644 index 0000000..aea7390 --- /dev/null +++ b/src/test/java/com/gxwebsoft/hjc/util/HjcWechatReadinessUtilTest.java @@ -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; + +/** + * 微信接入自检判定逻辑的单测。 + * + *

这段逻辑决定「排查方向」:把「配置缺失」误判成「配置就绪」会让人去翻微信后台, + * 把「appId 不一致」漏掉则会得到一个看起来像「商户号没绑定」的报错。 + * 所以这几条判定都要钉住。

+ */ +class HjcWechatReadinessUtilTest { + + @Test + void addCheckShouldRecordPassAndReturnIt() { + List> 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> checks = new ArrayList<>(); + HjcWechatReadinessUtil.addCheck(checks, "小程序配置", false, "why", "detail"); + + Map data = HjcWechatReadinessUtil.summary(10626, false, checks); + + assertEquals(10626, data.get("tenantId")); + assertEquals(Boolean.FALSE, data.get("ready")); + assertEquals(checks, data.get("checks")); + } +}