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