|
|
@@ -0,0 +1,301 @@
|
|
|
+package com.payment.platform.module.payment.leshouying.controller;
|
|
|
+
|
|
|
+import cn.hutool.core.util.IdUtil;
|
|
|
+import com.payment.platform.common.exception.BusinessException;
|
|
|
+import com.payment.platform.common.response.Result;
|
|
|
+import com.payment.platform.core.security.LoginUser;
|
|
|
+import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
|
|
|
+import com.payment.platform.module.payment.leshouying.dto.LsyCheckoutInfoVO;
|
|
|
+import com.payment.platform.module.payment.leshouying.dto.LsyCheckoutOrderVO;
|
|
|
+import com.payment.platform.module.payment.leshouying.dto.LsyCheckoutPayDTO;
|
|
|
+import com.payment.platform.module.payment.leshouying.entity.LsyCheckoutLinkEntity;
|
|
|
+import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
|
|
|
+import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
|
|
|
+import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
|
|
|
+import com.payment.platform.module.payment.leshouying.service.LsyCheckoutLinkService;
|
|
|
+import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
|
|
|
+import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
|
|
|
+import com.payment.platform.module.payment.leshouying.util.LsyAmountFloor;
|
|
|
+import jakarta.validation.Valid;
|
|
|
+import lombok.RequiredArgsConstructor;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
|
|
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
|
+import org.springframework.security.core.authority.AuthorityUtils;
|
|
|
+import org.springframework.security.core.context.SecurityContext;
|
|
|
+import org.springframework.security.core.context.SecurityContextHolder;
|
|
|
+import org.springframework.web.bind.annotation.GetMapping;
|
|
|
+import org.springframework.web.bind.annotation.PathVariable;
|
|
|
+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.RestController;
|
|
|
+
|
|
|
+import java.time.LocalDateTime;
|
|
|
+import java.time.OffsetDateTime;
|
|
|
+import java.time.format.DateTimeFormatter;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 顾客页(收款链接)—— <b>免登录</b>的两个端点:读商户名 / 按顾客输入的金额发起一次主扫支付。
|
|
|
+ *
|
|
|
+ * <h2>它解决什么</h2>
|
|
|
+ * 乐收赢的主扫是**一码一收**(每笔都要先下单、由上游发一个只对这笔有效的 {@code code_url}),
|
|
|
+ * 所以「贴一张静态码在墙上」在这条通道里做不到。折中办法是贴一个**我们自己的**链接:
|
|
|
+ * 链接与商户绑定、可无限次使用,顾客打开后自己输金额,我们当场建一笔单、把上游返回的
|
|
|
+ * {@code code_url} 交还给浏览器去跳转。这个控制器就是那个链接的落点。
|
|
|
+ *
|
|
|
+ * <h2>★★ 免登录:凭什么安全</h2>
|
|
|
+ * 打开链接的人没有登录态,本控制器**两个端点都不带任何认证**({@code SecurityConfig.WHITE_LIST}
|
|
|
+ * 里放行 {@code /payment/checkout/**})。安全依据是两件事,缺一不可:
|
|
|
+ * <ol>
|
|
|
+ * <li><b>定位键有全局唯一约束</b>:顾客页只能用 URL 里那一串 {@code token} 说话,而
|
|
|
+ * {@code uk_lsy_checkout_token} 单独建在 {@code token} 上(**不是** {@code (tenant_id, token)})
|
|
|
+ * ⇒ 「同一个 token 命中两行」在 schema 层建不出来。这是本仓通则的 **(甲)·预防**,
|
|
|
+ * 论证与「别把它收窄」写在 {@link LsyCheckoutLinkService} 的类 javadoc 与建表脚本里。</li>
|
|
|
+ * <li><b>token 是我们生成的 32 位随机十六进制</b>({@code IdUtil.fastSimpleUUID},
|
|
|
+ * 122 bit 熵),不是可枚举的序号 ⇒ 拿到别人的链接只能靠拿到那一串字符。</li>
|
|
|
+ * </ol>
|
|
|
+ * <p>★ 一条同层的硬约束:{@code pay_lsy_checkout_link} <b>必须</b>登记在
|
|
|
+ * {@code TenantInnerInterceptor.CONDITIONAL_TABLES}。漏登记 ⇒ 拦截器给这条查询追加
|
|
|
+ * {@code AND tenant_id = 0}(未认证时 {@code getTenantId()} 返回 0)⇒ <b>每条链接都查不到</b>,
|
|
|
+ * 表现是顾客页一律「链接无效」,而日志里一个字都没有。
|
|
|
+ *
|
|
|
+ * <h2>★★ 租户身份怎么来的:从链接那一行,而且要在库操作之前绑好</h2>
|
|
|
+ * 顾客页的请求里没有任何租户维度,唯一的来源是 {@link LsyCheckoutLinkEntity#getTenantId()}。
|
|
|
+ * 定位到那一行之后、**任何一次属于该租户的库操作之前**,本类把租户写进
|
|
|
+ * {@code SecurityContext}(见 {@link #scopedTo}),请求结束再**还原**。
|
|
|
+ *
|
|
|
+ * <p>为什么值得新开这个模式(本仓此前没有非 Filter 代码动过 SecurityContext):
|
|
|
+ * 这条路径上的三张表({@code pay_lsy_checkout_link} / {@code pay_lsy_merchant} /
|
|
|
+ * {@code pay_lsy_order})<b>全都在 CONDITIONAL_TABLES 里</b>,而拦截器对它们
|
|
|
+ * <b>无认证时是「跳过过滤」</b>({@code ignoreTable} 返回 true)—— 也就是说,不绑租户时
|
|
|
+ * 整条路径跑在**完全没有租户谓词**的状态下,正确性全部押在我们手写的每一个 WHERE 上。
|
|
|
+ * 绑上之后,拦截器会**独立地再写一遍**同一个边界:{@code requireByMchNoForTenant} 自带的
|
|
|
+ * {@code tenant_id = ?} 与拦截器追加的那一条互为对照 —— 手写谓词写错(传错变量、少写一列)
|
|
|
+ * 时,另一条还在。
|
|
|
+ *
|
|
|
+ * <p>★ 这**不是**「不绑就查不到」:不绑时拦截器跳过过滤,查询照样命中。所以这里绑的是
|
|
|
+ * **纵深防御**,不是修一个已经坏掉的东西 —— 别把这段读成「原先有 bug」。
|
|
|
+ *
|
|
|
+ * <p>★ 还原用 {@code setContext(previous)}(整个上下文换回去),不是
|
|
|
+ * {@code setAuthentication(null)}:后者在「本来就没有认证」之外还抹掉了上下文里可能有的别的东西。
|
|
|
+ * 而且它在 {@code finally} 里 —— 这是容器的线程,不是我们自己的线程。
|
|
|
+ *
|
|
|
+ * <h2>与另外两条腿的关系:第三份落库装配,**有意不合并**</h2>
|
|
|
+ * {@link #newOrder} 与收银台({@code LeshouyingCashierController.newOrder})及开放 API
|
|
|
+ * ({@code LeshouyingOpenController.newOrder})是同一件事的三份实现。合并会迫使另外两条
|
|
|
+ * 腿的既有测试全部改构造方式,而它们**线上正在跑**。代价是各自漂移,所以差异逐条写在下表里。
|
|
|
+ *
|
|
|
+ * <table border="1">
|
|
|
+ * <caption>三个入口的落库字段差异(改任一侧先看这张表)</caption>
|
|
|
+ * <tr><th>字段</th><th>开放 API</th><th>收银台</th><th>本类(顾客页)</th></tr>
|
|
|
+ * <tr><td>{@code outTradeNo}</td><td>调用方传入</td><td>{@code "SK"+雪花}</td>
|
|
|
+ * <td>{@code "CS"+雪花}(三处前缀互不相同 ⇒ 拿到单号就知道它从哪条腿来)</td></tr>
|
|
|
+ * <tr><td>租户来源</td><td>ApiKey 认证上下文</td><td>JWT 认证上下文</td>
|
|
|
+ * <td><b>链接那一行</b>(唯一一处不是认证上下文)</td></tr>
|
|
|
+ * <tr><td>{@code apiKeyId}</td><td>认证上下文</td><td>null</td><td>null(顾客页没有下游身份)</td></tr>
|
|
|
+ * <tr><td>{@code notifyUrl}</td><td>调用方传入</td><td><b>有意不设</b></td><td><b>有意不设</b>(同收银台)</td></tr>
|
|
|
+ * <tr><td>{@code orderBody}</td><td>{@code dto.getOrderBody()}</td><td>{@code dto.getRemark()}</td>
|
|
|
+ * <td>不设(null)—— 顾客页只有金额一个输入框,多一个字段就是多一处要解释的东西</td></tr>
|
|
|
+ * </table>
|
|
|
+ *
|
|
|
+ * <p>★ {@code notify_url} 为什么留空,论证与收银台**逐字同一份**(那里写了完整的三段:
|
|
|
+ * 一列两义会自环、上游回调地址其实来自平台级配置、改了会牵动已评审的线上代码),
|
|
|
+ * 见 {@code LeshouyingCashierController} 类 javadoc 的「为什么收银单不设 notify_url」。
|
|
|
+ * 这里不复制那三段,只留结论与指针 —— 顾客页连「顾客付完页面变绿」这件事都不做
|
|
|
+ * (页面把顾客送走后就不再回来),比收银台更没有理由设它。
|
|
|
+ *
|
|
|
+ * <h2>端点清单</h2>
|
|
|
+ * <table border="1">
|
|
|
+ * <caption>两个端点都不带权限串(免登录)</caption>
|
|
|
+ * <tr><th>方法</th><th>完整路径</th><th>用途</th></tr>
|
|
|
+ * <tr><td>GET</td><td>{@code /payment/checkout/{token}}</td><td>顾客页开屏:显示商户名</td></tr>
|
|
|
+ * <tr><td>POST</td><td>{@code /payment/checkout/{token}/orders}</td>
|
|
|
+ * <td>顾客按下「去支付」:建单 → 上游下单 → 回 {@code code_url}</td></tr>
|
|
|
+ * </table>
|
|
|
+ * <p>★ 路径段不含 {@code collect}/{@code collection} 字样,与 {@code /payment/collection/**}
|
|
|
+ * (管理端 + 免登录审核通知)在字形上分得开:两者只是**看起来**像,前缀不同、认证面也不同。
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+// 总开关:leshouying.enabled=false 时本类不装配(端点不存在),见 LeshouyingProperties#enabled
|
|
|
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
|
|
|
+@RestController
|
|
|
+@RequestMapping("/payment/checkout")
|
|
|
+@RequiredArgsConstructor
|
|
|
+public class LeshouyingCheckoutController {
|
|
|
+
|
|
|
+ /** 与 {@code LsyOrderService} / 收银台同一份交易时间格式。 */
|
|
|
+ private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
|
|
+
|
|
|
+ /** 单号前缀:{@code CS} = checkout。三个入口三份前缀,见类 javadoc 的差异表。 */
|
|
|
+ private static final String OTN_PREFIX = "CS";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 上游那一跳失败时给顾客的一句话。
|
|
|
+ *
|
|
|
+ * <p>不含上游词汇、不含错误码、不教顾客「稍后重试」以外的事 —— 顾客能做且只能做的
|
|
|
+ * 就是再按一次(再按会生成**新的单号**,旧行既不占资金也不再被使用,重试完全安全)。
|
|
|
+ */
|
|
|
+ private static final String MSG_PAY_FAILED = "支付发起失败,请稍后重试";
|
|
|
+
|
|
|
+ private final LsyCheckoutLinkService linkService;
|
|
|
+ private final LsyMerchantService merchantService;
|
|
|
+ private final LsyOrderService orderService;
|
|
|
+ private final LsyOrderMapper orderMapper;
|
|
|
+
|
|
|
+ // ──────────────────────────── 1. 开屏:显示商户名 ────────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 顾客页开屏读一次,拿商户显示名。
|
|
|
+ *
|
|
|
+ * <p>这里只碰 {@code pay_lsy_checkout_link} 一张表,**不绑租户上下文**:按 token 定位是
|
|
|
+ * 未认证读,唯一依据是那个全局唯一索引(类 javadoc 第 1 条)。返回值里故意只有显示名,
|
|
|
+ * 见 {@link LsyCheckoutInfoVO}。
|
|
|
+ *
|
|
|
+ * @throws BusinessException 404 —— 未知 / 空白 / 已停用一律同一条(由服务层统一给出)
|
|
|
+ */
|
|
|
+ @GetMapping("/{token}")
|
|
|
+ public Result<LsyCheckoutInfoVO> info(@PathVariable String token) {
|
|
|
+ LsyCheckoutLinkEntity link = linkService.requireByToken(token);
|
|
|
+ LsyCheckoutInfoVO vo = new LsyCheckoutInfoVO();
|
|
|
+ vo.setMerchantName(link.getMerchantName());
|
|
|
+ return Result.ok(vo);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ──────────────────────────── 2. 下单:建单 → 上游下单 → 交还链接 ────────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 顾客按下「去支付」。
|
|
|
+ *
|
|
|
+ * <p>顺序固定,四步都不可换:
|
|
|
+ * <ol>
|
|
|
+ * <li><b>解析 token</b> —— 免登录读,未认证状态下按全局唯一的 token 定位(类 javadoc);</li>
|
|
|
+ * <li><b>金额下限</b> —— 见下面的双保险说明;</li>
|
|
|
+ * <li><b>绑租户 → 本地闸 → 落库 → 上游下单</b> —— 本地闸({@code requireByMchNoForTenant})
|
|
|
+ * 必须排在**任何上游调用之前**:反了不是「同样安全」,那等于拿一个还没证明属于本租户的
|
|
|
+ * 商户号去发起一次上游调用(同收银台与开放 API 那条腿的规矩);</li>
|
|
|
+ * <li><b>还原上下文</b>({@code finally})。</li>
|
|
|
+ * </ol>
|
|
|
+ *
|
|
|
+ * <p>★ <b>落库必须在 {@code createQrOrder} 之前</b>:上游下单要用 {@code order.getId()}
|
|
|
+ * 做条件更新(写 {@code code_url} 与状态),而 id 由 insert 生成。另外
|
|
|
+ * {@code pay_lsy_order.mch_no} 是 NOT NULL 且无默认值 ⇒ 装配必须先于 insert。
|
|
|
+ *
|
|
|
+ * <p>★ <b>金额下限判两道</b>:{@link LsyCheckoutPayDTO} 上的 {@code @Min}(真请求走这条)
|
|
|
+ * 与下面的显式判断(本仓所有控制器用例都是普通方法直调,Spring 的参数校验一步都不参与
|
|
|
+ * ⇒ 只挂注解的话这条规则在测试里没有任何守卫)。文案两边同源,都是
|
|
|
+ * {@link LsyAmountFloor#MESSAGE}。
|
|
|
+ */
|
|
|
+ @PostMapping("/{token}/orders")
|
|
|
+ public Result<LsyCheckoutOrderVO> create(@PathVariable String token,
|
|
|
+ @Valid @RequestBody LsyCheckoutPayDTO dto) {
|
|
|
+ LsyCheckoutLinkEntity link = linkService.requireByToken(token);
|
|
|
+
|
|
|
+ Long amount = dto == null ? null : dto.getAmount();
|
|
|
+ if (amount == null || amount < LsyAmountFloor.MIN) {
|
|
|
+ // ★ 这里**不做**任何库操作、不打上游:顾客只会看到一句能照做的话。
|
|
|
+ throw new BusinessException(400, "amount " + LsyAmountFloor.MESSAGE);
|
|
|
+ }
|
|
|
+
|
|
|
+ SecurityContext previous = SecurityContextHolder.getContext();
|
|
|
+ try {
|
|
|
+ SecurityContextHolder.setContext(scopedTo(link.getTenantId()));
|
|
|
+
|
|
|
+ LsyMerchantEntity merchant = merchantService.requireByMchNoForTenant(
|
|
|
+ link.getMchNo(), link.getTenantId());
|
|
|
+
|
|
|
+ LsyOrderEntity order = newOrder(link, merchant, amount);
|
|
|
+ orderMapper.insert(order);
|
|
|
+
|
|
|
+ try {
|
|
|
+ orderService.createQrOrder(order, null);
|
|
|
+ } catch (LeshouyingApiException e) {
|
|
|
+ // 上游这一跳失败:本地这一行会留在 CREATED 且没有 code_url。
|
|
|
+ // 它**不是死行** —— LsyReconcileScheduler.expireStaleOrders 扫的是整个
|
|
|
+ // LsyOrderStatus.PENDING(含 CREATED),超过 absoluteExpireHours 会被强制
|
|
|
+ // 收敛为 EXPIRED。所以不在这里额外清理(三条腿在这件事上行为一致)。
|
|
|
+ log.warn("收款链接下单未成功: outTradeNo={}, linkId={}, 原因={}",
|
|
|
+ order.getOutTradeNo(), link.getId(), e.getMessage());
|
|
|
+ return Result.of(500, MSG_PAY_FAILED);
|
|
|
+ }
|
|
|
+ return Result.ok(toVo(order));
|
|
|
+ } finally {
|
|
|
+ SecurityContextHolder.setContext(previous);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ──────────────────────────── 落库装配(第三条腿) ────────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 组装顾客单。字段差异逐条列在类 javadoc 的表里。
|
|
|
+ *
|
|
|
+ * <p>★ 三个「服务端决定」的字段(单号 / 交易时间 / 流水号)都在这里生成:
|
|
|
+ * 顾客页的请求体里只有金额,多一个可传的字段就是多一处能被塞进别的值的输入。
|
|
|
+ * <p>★ 单号用雪花而不是时间戳式自增:唯一性是 {@code (tenant_id, out_trade_no)},
|
|
|
+ * 两个租户同一秒各建一单时,时间戳式自增会撞在一起。
|
|
|
+ */
|
|
|
+ private LsyOrderEntity newOrder(LsyCheckoutLinkEntity link, LsyMerchantEntity merchant, Long amount) {
|
|
|
+ OffsetDateTime now = OffsetDateTime.now();
|
|
|
+ LsyOrderEntity o = new LsyOrderEntity();
|
|
|
+ o.setTenantId(link.getTenantId());
|
|
|
+
|
|
|
+ o.setOutTradeNo(OTN_PREFIX + IdUtil.getSnowflakeNextIdStr());
|
|
|
+ o.setMchNo(merchant.getMchNo());
|
|
|
+ o.setShopNo(merchant.getShopNo());
|
|
|
+
|
|
|
+ o.setPayScene("QRCODE");
|
|
|
+ // 聚合码不传 pay_type —— 上游按用户扫出来的码自动识别支付宝/微信。
|
|
|
+ o.setPayType(null);
|
|
|
+ o.setPayTraceNo(IdUtil.fastSimpleUUID());
|
|
|
+ o.setPayTime(LocalDateTime.now().format(FMT));
|
|
|
+
|
|
|
+ // 落库这一刻还没下单 ⇒ 只能是 CREATED。createQrOrder 之后会按上游应答改成 PAYING/FAIL。
|
|
|
+ o.setStatus("CREATED");
|
|
|
+ o.setTotalAmount(amount);
|
|
|
+ o.setRefundedAmount(0L);
|
|
|
+ o.setQueryCount(0);
|
|
|
+
|
|
|
+ o.setNotifyStatus("PENDING");
|
|
|
+ o.setNotifyCount(0);
|
|
|
+ // ★ notifyUrl 有意留空,apiKeyId 也有意留空 —— 见类 javadoc 的差异表。
|
|
|
+
|
|
|
+ o.setCreatedTime(now);
|
|
|
+ o.setUpdatedTime(now);
|
|
|
+ return o;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ──────────────────────────── 租户上下文 ────────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 造一个**只带租户**的上下文:没有角色、没有权限、{@code isSuperuser=false}。
|
|
|
+ *
|
|
|
+ * <p>★ {@code isSuperuser} 必须**显式写 false**:{@code TenantInnerInterceptor.ignoreTable}
|
|
|
+ * 的第一条就是超管直通(返回 true = 不做任何租户过滤)。留 null 虽然也走不到那一支
|
|
|
+ * (判据是 {@code user.getIsSuperuser() != null && user.getIsSuperuser()}),
|
|
|
+ * 但这条路径上「不过滤」正是我们要避免的那一态 —— 别让它由 null 的语义顺手决定。
|
|
|
+ *
|
|
|
+ * <p>★ 权限列表给空:这条路径不经过任何 {@code @PreAuthorize},
|
|
|
+ * 这个主体的唯一用途就是让拦截器读到 {@code tenantId}。
|
|
|
+ */
|
|
|
+ private static SecurityContext scopedTo(Long tenantId) {
|
|
|
+ LoginUser user = new LoginUser();
|
|
|
+ user.setTenantId(tenantId);
|
|
|
+ user.setIsSuperuser(false);
|
|
|
+ user.setAuthorities(AuthorityUtils.NO_AUTHORITIES);
|
|
|
+
|
|
|
+ SecurityContext ctx = SecurityContextHolder.createEmptyContext();
|
|
|
+ ctx.setAuthentication(new UsernamePasswordAuthenticationToken(
|
|
|
+ user, null, AuthorityUtils.NO_AUTHORITIES));
|
|
|
+ return ctx;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ──────────────────────────── 出口 ────────────────────────────
|
|
|
+
|
|
|
+ private static LsyCheckoutOrderVO toVo(LsyOrderEntity o) {
|
|
|
+ LsyCheckoutOrderVO vo = new LsyCheckoutOrderVO();
|
|
|
+ vo.setOutTradeNo(o.getOutTradeNo());
|
|
|
+ vo.setCodeUrl(o.getCodeUrl());
|
|
|
+ vo.setTotalAmount(o.getTotalAmount());
|
|
|
+ return vo;
|
|
|
+ }
|
|
|
+}
|