fix(payment): 退款结果在读订单与列表时收敛,并修掉通道流水号被覆盖
退款提交时通道只回「处理中」,最终结果只有通道知道;退款通知和支付通知一样到不了本地服务,
于是本地只能等对账任务的下一个窗口。开发库实测:14:37:31 提交退款、14:43:41 才对账收敛,中间
六分钟里钱已经退回人才账户,人才端订单却仍显示「待核销」,商户端也当它是「可核销的订单」——
钱到账和订单状态对不上,商户去核销又会被资金边界拒绝。
读路径主动收敛:
- HuifuRefundService 新增 confirmPendingRefunds(按人才)/ confirmPendingRefundsOfMerchant
(按商户),把在途退款各问通道一次。自己的事务、忽略单笔失败,且必须是调用方事务的第一条
读语句——MySQL 的一致性快照是在那条读语句上定下的,排在后面就只能读到收敛前的旧快照。
提交后 20 秒内不查,避免刚提交就打通道;一轮最多两笔,读列表不会被拖长。
- 人才端读订单(待使用/订单状态页)与读订单列表、商户端读订单列表都先收敛一次。
状态词表补上退款维度(原来只看支付/核销,退款中的订单一律落到「待核销」):
- 人才端列表:refund_status 非 0 的订单归入「已取消」,订单状态页区分退款中 / 退款成功 /
退款失败;退款中的订单也能打开这一页(此前报「订单当前未取消」)。未支付就取消的订单语义
不变:仍是「已取消 / 无需退款」,由 CancelledOrderStateTests 守住。
- 商户端:新增 cancelled / refunded 两个状态(文案区分「退款中」「已退款」),「待核销」筛选
排除退款订单,新增「已取消/已退款」筛选;列表徽标走接口文案并给灰色样式,点击已取消/已退款
订单进详情看状态(不再按「已完成」打开),详情页显示对应标题与说明、核销时间显示 —。
- 退款中/已退款的订单不再发核销码:钱要退给人才、商户也核销不了,发了只会让双方白跑一趟。
- 人才端待使用页遇到已退款/已取消订单直接进订单状态页。
顺带修一个数据被覆盖的缺陷:查单确认退款成功时 provider_refund_no 被无条件写成空串——通道退款
流水号只在发起退款那次回执里有,退款查询回执不带它,于是唯一的通道凭证被擦掉(开发库 refund
44 与 43 都是空)。改为保留已有值,与其他路径同样的 coalesce(nullif(?, ''), ...) 写法。
测试:新增 PendingRefundConfirmationTests 8 项、CancelledOrderStateTests 2 项,MerchantWorkServiceTests
增至 7 项。其中「通道流水号不被覆盖」那项退掉修复验证过会红(but was: "")。
全量 273 项,仅 docs/agents/testing.md 所列 3 项基线失败,无新增。
| | |
| | | onUnload(() => stopRefresh()) |
| | | |
| | | const goBack = () => safeBack('/pages/order/order') |
| | | const loadOrder = () => getPendingUseOrder(orderId.value, talentId.value, (data:any) => { productName.value=data.productNameSnapshot||productName.value; productCoverUrl.value=resolveApiUrl(data.productCoverUrlSnapshot||'/static/home-assets/top.jpg'); originalAmount.value=Number(data.originalAmount).toFixed(2); payAmount.value=Number(data.payAmount).toFixed(2); discountAmount.value=Number(data.discountAmount).toFixed(2); merchantName.value=data.merchantNameSnapshot||''; merchantAddress.value=data.merchantAddressSnapshot||''; merchantMobile.value=data.merchantMobileSnapshot||''; merchantLatitude.value=Number(data.merchantLatitudeSnapshot); merchantLongitude.value=Number(data.merchantLongitudeSnapshot); createdAt.value=(''+data.createdAt).replace('T',' ').substring(0,19); verifyTime.value=data.verifyTime?(''+data.verifyTime).replace('T',' ').substring(0,19):'-'; canRefund.value=data.canRefund===true }, () => {}) |
| | | const loadOrder = () => getPendingUseOrder(orderId.value, talentId.value, (data:any) => { |
| | | // 已退款(含退款中)或已取消的订单不再是「待使用」:这一页不发核销码,直接进订单状态页看退款结果。 |
| | | if (Number(data.orderStatus) == 2 || data.refundStatus != null) { |
| | | uni.redirectTo({ url: '/pages/order-cancelled/order-cancelled?recordId=' + encodeURIComponent(recordId.value) + '&orderId=' + encodeURIComponent(orderId.value) + '&talentId=' + encodeURIComponent(talentId.value) }) |
| | | return |
| | | } |
| | | productName.value=data.productNameSnapshot||productName.value; productCoverUrl.value=resolveApiUrl(data.productCoverUrlSnapshot||'/static/home-assets/top.jpg'); originalAmount.value=Number(data.originalAmount).toFixed(2); payAmount.value=Number(data.payAmount).toFixed(2); discountAmount.value=Number(data.discountAmount).toFixed(2); merchantName.value=data.merchantNameSnapshot||''; merchantAddress.value=data.merchantAddressSnapshot||''; merchantMobile.value=data.merchantMobileSnapshot||''; merchantLatitude.value=Number(data.merchantLatitudeSnapshot); merchantLongitude.value=Number(data.merchantLongitudeSnapshot); createdAt.value=(''+data.createdAt).replace('T',' ').substring(0,19); verifyTime.value=data.verifyTime?(''+data.verifyTime).replace('T',' ').substring(0,19):'-'; canRefund.value=data.canRefund===true }, () => {}) |
| | | const stopRefresh=()=>{if(refreshTimer!=0){clearTimeout(refreshTimer);refreshTimer=0}} |
| | | const refreshCode=()=>{stopRefresh(); if(orderId.value=='')return; codeError.value=false; issueOrderVerificationCode(orderId.value,talentId.value,(data:any)=>{codeImage.value=resolveApiUrl(data.imagePath as string);codeText.value='核销码 '+(data.displayCode as string)+',请向商家出示';refreshTimer=setTimeout(()=>refreshCode(),Math.max(1,Number(data.refreshAfterSeconds)-3)*1000)},()=>{codeImage.value='';codeError.value=true;codeText.value='点击重新生成核销码'})} |
| | | const openNavigation = () => { if(merchantLatitude.value==0||merchantLongitude.value==0){uni.showToast({title:'商户暂未配置导航地址',icon:'none'});return};uni.openLocation({latitude:merchantLatitude.value,longitude:merchantLongitude.value,name:merchantName.value,address:merchantAddress.value,scale:16}) } |
| | |
| | | export type OrderCategory = 'group' | 'store' | 'stay' |
| | | export type OrderStatus = 'all' | 'pending_verification' | 'pending_payment' | 'completed' |
| | | export type OrderStatus = 'all' | 'pending_verification' | 'pending_payment' | 'completed' | 'closed' |
| | | |
| | | export const getPendingStatusByCategory = (category: string): OrderStatus => { |
| | | if (category == 'store') return 'pending_payment' |
| | |
| | | if (status == 'pending_verification') return '待核销' |
| | | if (status == 'pending_payment') return '待支付' |
| | | if (status == 'completed') return '已完成' |
| | | if (status == 'closed') return '已取消/已退款' |
| | | return '全部' |
| | | } |
| | |
| | | paidAmount.value = formatCents(detail.payAmount) |
| | | subsidyAmount.value = formatCents(detail.subsidyAmount) |
| | | createdAt.value = detail.createdAt.replace('T', ' ') |
| | | verificationAt.value = detail.verifyTime.length > 0 ? detail.verifyTime.replace('T', ' ') : '待核销' |
| | | // 已取消/已退款:订单已经离开可核销范围,不能再说成「待核销」,也没有核销时间。 |
| | | const closed = detail.status == 'cancelled' || detail.status == 'refunded' |
| | | verificationAt.value = detail.verifyTime.length > 0 ? detail.verifyTime.replace('T', ' ') : (closed ? '—' : '待核销') |
| | | customerName.value = detail.customerName |
| | | customerPhone.value = detail.customerMobile |
| | | cardLevel.value = detail.cardLevel.length > 0 ? detail.cardLevel : '人才卡' |
| | |
| | | isStoreCompleted.value = detail.category == 'store' && detail.status == 'completed' |
| | | canRevokeVerification.value = detail.canRevoke |
| | | revokeExpiredMessage.value = detail.revokeReason.length > 0 ? detail.revokeReason : '已过可撤销时间' |
| | | statusTitle.value = isCompleted.value ? '已完成' : '待核销' |
| | | statusDesc.value = isCompleted.value ? '订单已完成核销,相关权益已结算。' : '订单已完成支付,请确认后即可核销。' |
| | | statusIcon.value = isCompleted.value ? '/static/icons/lucide/badge-check.svg' : '/static/icons/lucide/clock-3.svg' |
| | | if (closed) { |
| | | statusTitle.value = detail.statusText.length > 0 ? detail.statusText : (detail.status == 'cancelled' ? '已取消' : '已退款') |
| | | statusDesc.value = detail.status == 'cancelled' ? '订单在支付前已取消,无需核销。' : '退款已受理或完成,款项退回人才原支付账户,订单不再核销。' |
| | | statusIcon.value = '/static/icons/lucide/x.svg' |
| | | } else { |
| | | statusTitle.value = isCompleted.value ? '已完成' : '待核销' |
| | | statusDesc.value = isCompleted.value ? '订单已完成核销,相关权益已结算。' : '订单已完成支付,请确认后即可核销。' |
| | | statusIcon.value = isCompleted.value ? '/static/icons/lucide/badge-check.svg' : '/static/icons/lucide/clock-3.svg' |
| | | } |
| | | } |
| | | |
| | | onLoad((options: any) => { |
| | |
| | | |
| | | <view v-if="listLoadState == 'success'" class="order-list"> |
| | | <view v-for="order in orders" :key="order.id" class="order-card" :class="order.category == 'stay' ? 'stay-order-card' : (order.category == 'store' ? 'store-order-card' : '')" @tap="openOrderItem(order)"> |
| | | <view class="customer-row"><view class="customer-info"><image class="customer-icon" src="/static/icons/lucide/circle-user-round.svg" mode="aspectFit"></image><text class="customer-name">{{order.customerName}}</text><text class="customer-phone">{{order.customerMobile}}</text></view><text class="status" :class="order.status == 'completed' ? 'verified' : 'pending'">{{order.statusText}}</text></view> |
| | | <view class="customer-row"><view class="customer-info"><image class="customer-icon" src="/static/icons/lucide/circle-user-round.svg" mode="aspectFit"></image><text class="customer-name">{{order.customerName}}</text><text class="customer-phone">{{order.customerMobile}}</text></view><text class="status" :class="order.status == 'completed' ? 'verified' : ((order.status == 'cancelled' || order.status == 'refunded') ? 'closed' : 'pending')">{{order.statusText}}</text></view> |
| | | <text v-if="order.category != 'stay'" class="order-name">{{order.title}}</text> |
| | | <view v-if="order.category == 'stay'" class="stay-title-row"><text class="stay-order-name">{{order.title}}</text><text class="card-tag">{{order.cardLevel}}</text></view> |
| | | <view v-else class="price-row" :class="order.category == 'store' ? 'store-price-row' : ''"><view class="price-group"><text class="price">¥{{formatAmount(order.payAmount)}}</text><text v-if="order.orderAmount > order.payAmount" class="original-price">¥{{formatAmount(order.orderAmount)}}</text></view><text class="card-tag">{{order.cardLevel}}</text></view> |
| | |
| | | <view class="status-option" :class="statusFilter == 'all' ? 'selected' : ''" @tap="selectStatus('all')"><text class="status-option-text">全部</text><text v-if="statusFilter == 'all'" class="status-selected-mark">已选</text></view> |
| | | <view class="status-option" :class="statusFilter == pendingStatus ? 'selected' : ''" @tap="selectStatus(pendingStatus)"><text class="status-option-text">{{pendingStatusText}}</text><text v-if="statusFilter == pendingStatus" class="status-selected-mark">已选</text></view> |
| | | <view class="status-option" :class="statusFilter == 'completed' ? 'selected' : ''" @tap="selectStatus('completed')"><text class="status-option-text">已完成</text><text v-if="statusFilter == 'completed'" class="status-selected-mark">已选</text></view> |
| | | <view class="status-option" :class="statusFilter == 'closed' ? 'selected' : ''" @tap="selectStatus('closed')"><text class="status-option-text">已取消/已退款</text><text v-if="statusFilter == 'closed'" class="status-selected-mark">已选</text></view> |
| | | </view> |
| | | <view class="status-cancel" @tap="closeStatusFilter"><text class="status-cancel-text">取消</text></view> |
| | | </view> |
| | |
| | | const query = 'id=' + encodeURIComponent(order.id) + '&orderNo=' + encodeURIComponent(order.orderNo) |
| | | if (order.status == 'pending_verification') { |
| | | uni.navigateTo({ url: '/pages/verification-detail/verification-detail?' + query }) |
| | | return |
| | | } |
| | | // 已取消/已退款的订单进详情看状态,不能按「已完成」打开,否则会把取消说成完成。 |
| | | if (order.status == 'cancelled' || order.status == 'refunded') { |
| | | uni.navigateTo({ url: '/pages/order-detail/order-detail?status=' + order.status + '&' + query }) |
| | | return |
| | | } |
| | | uni.navigateTo({ url: '/pages/order-detail/order-detail?status=completed&' + query }) |
| | |
| | | .status { font-size: 24rpx; line-height: 34rpx; font-weight: 600; } |
| | | .pending { color: #FF5A42; } |
| | | .verified { color: #98A3B4; } |
| | | .closed { color: #98A3B4; } |
| | | .order-name { color: #182236; font-size: 31rpx; line-height: 44rpx; font-weight: 700; margin-top: 26rpx; } |
| | | .price-row { display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin-top: 18rpx; } |
| | | .store-price-row { margin-top: 24rpx; } |
| | |
| | | import com.talentcard.server.business.merchant.auth.MerchantPrincipal; |
| | | import com.talentcard.server.business.order.OrderFundBoundaryService; |
| | | import com.talentcard.server.business.payment.PaymentFundConfirmationService; |
| | | import com.talentcard.server.business.payment.HuifuRefundService; |
| | | import com.talentcard.server.business.portal.ProductPriceCalculator; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import com.talentcard.server.common.ErrorCode; |
| | |
| | | private final ManualConsumptionService manualConsumptionService; |
| | | private final OrderFundBoundaryService orderFundBoundary; |
| | | private final com.talentcard.server.business.order.OrderVerificationService orderVerificationService; |
| | | private final HuifuRefundService refundService; |
| | | |
| | | public MerchantWorkService(JdbcTemplate jdbcTemplate, ManualConsumptionService manualConsumptionService, |
| | | OrderFundBoundaryService orderFundBoundary, |
| | | com.talentcard.server.business.order.OrderVerificationService orderVerificationService) { |
| | | com.talentcard.server.business.order.OrderVerificationService orderVerificationService, |
| | | HuifuRefundService refundService) { |
| | | this.jdbcTemplate = jdbcTemplate; |
| | | this.manualConsumptionService = manualConsumptionService; |
| | | this.orderFundBoundary = orderFundBoundary; |
| | | this.orderVerificationService = orderVerificationService; |
| | | this.refundService = refundService; |
| | | } |
| | | |
| | | @Transactional(readOnly = true) |
| | |
| | | if (query != null && "stay".equals(normalize(query.category()))) { |
| | | return freeStayOrders(merchantId, storeId, page, size, query); |
| | | } |
| | | // 退款到不到最终结果只有通道知道,退款通知又会遇到和支付通知一样的问题(本地/内网环境收不到)。 |
| | | // 读列表之前先把这家商户在途的退款收敛一次,免得商户看到的退款状态比人才端晚几分钟。这一步必须 |
| | | // 排在本事务的第一条读语句之前,否则列表读到的是收敛前的旧快照。免费入住没有退款,走上面的分支。 |
| | | refundService.confirmPendingRefundsOfMerchant(merchantId, HuifuRefundService.PENDING_CONFIRM_LIMIT); |
| | | int safePage = Math.max(page, 1); |
| | | int safeSize = Math.min(Math.max(size, 1), 100); |
| | | OrderFilters filters = filters(query); |
| | |
| | | select o.id, o.order_no, tu.name as talent_name, tu.mobile as talent_mobile, tu.gender, |
| | | coalesce(nullif(o.card_level_snapshot, ''), tc.card_level) as card_level, |
| | | b.benefit_name, o.product_name_snapshot, o.order_type, o.order_amount, o.subsidy_amount, o.pay_amount, |
| | | o.order_status, o.verify_status, o.created_at, verified.verify_time |
| | | o.order_status, o.verify_status, o.refund_status, o.created_at, verified.verify_time |
| | | from benefit_order o |
| | | left join talent_user tu on tu.id = o.talent_id |
| | | left join talent_card tc on tc.id = o.card_id |
| | |
| | | o.merchant_id, m.merchant_name, m.merchant_rights_config, |
| | | o.product_id, o.product_name_snapshot, p.rights_config, |
| | | o.order_type, o.order_amount, o.base_pay_amount, o.coupon_amount, |
| | | o.subsidy_amount, o.pay_amount, o.order_status, o.verify_status, o.created_at, |
| | | o.subsidy_amount, o.pay_amount, o.order_status, o.verify_status, o.refund_status, o.created_at, |
| | | vr.verify_time, vr.status as verify_record_status |
| | | from benefit_order o |
| | | left join talent_user tu on tu.id = o.talent_id |
| | |
| | | r.card_id, tc.card_no, tc.card_level, r.merchant_id, m.merchant_name, m.merchant_rights_config, |
| | | '免费入住' as benefit_name, '1天免费入住' as product_name_snapshot, |
| | | 'FREE_STAY' as order_type, 0 as order_amount, 0 as base_pay_amount, 0 as coupon_amount, |
| | | r.subsidy_amount, 0 as pay_amount, 1 as order_status, 1 as verify_status, |
| | | r.subsidy_amount, 0 as pay_amount, 1 as order_status, 1 as verify_status, 0 as refund_status, |
| | | r.verify_time as created_at, r.verify_time, r.status as verify_record_status |
| | | from verify_record r |
| | | left join talent_user tu on tu.id = r.talent_id |
| | |
| | | r.card_id, tc.card_level, r.merchant_id, m.merchant_name, |
| | | '免费入住' as benefit_name, '1天免费入住' as product_name_snapshot, |
| | | 'FREE_STAY' as order_type, 0 as order_amount, 0 as base_pay_amount, 0 as coupon_amount, |
| | | r.subsidy_amount, 0 as pay_amount, 1 as order_status, 1 as verify_status, |
| | | r.subsidy_amount, 0 as pay_amount, 1 as order_status, 1 as verify_status, 0 as refund_status, |
| | | r.verify_time as created_at, r.verify_time |
| | | from verify_record r |
| | | left join talent_user tu on tu.id = r.talent_id |
| | |
| | | String category = "GROUP_BUY".equals(orderType) ? "group" : ("STORE_CONSUME".equals(orderType) ? "store" : "stay"); |
| | | Long orderStatus = number(order.get("orderStatus")); |
| | | Long verifyStatus = number(order.get("verifyStatus")); |
| | | Long refundStatus = number(order.get("refundStatus")); |
| | | LocalDateTime verifyTime = time(order.get("verifyTime")); |
| | | // 核销状态必须同时有有效核销记录和核销时间。历史数据可能只更新了 |
| | | // benefit_order.verify_status,不能据此把订单展示为已完成。 |
| | | boolean verified = verifyStatus != null && verifyStatus == 1 && verifyTime != null; |
| | | String status = orderStatus != null && orderStatus == 0 ? "pending_payment" : (verified ? "completed" : "pending_verification"); |
| | | // 与列表同一口径:待支付 → 退款中/已退款 → 已取消 → 已完成 → 待核销。 |
| | | String status; |
| | | if (orderStatus != null && orderStatus == 0) status = "pending_payment"; |
| | | else if (refundStatus != null && refundStatus != 0) status = "refunded"; |
| | | else if (orderStatus != null && orderStatus == 2) status = "cancelled"; |
| | | else status = verified ? "completed" : "pending_verification"; |
| | | result.put("category", category); |
| | | result.put("status", status); |
| | | result.put("statusText", "pending_payment".equals(status) ? "待支付" : ("completed".equals(status) ? "已完成" : "待核销")); |
| | | result.put("statusText", statusText(status, refundStatus)); |
| | | result.put("customerName", displayName(order.get("talentName"), number(order.get("gender")))); |
| | | result.put("customerMobile", maskMobile(String.valueOf(order.get("talentMobile")))); |
| | | result.put("title", orderTitle(order, category)); |
| | |
| | | String status = normalize(value.status()); |
| | | if (!status.isEmpty() && !"all".equals(status)) { |
| | | switch (status) { |
| | | case "pending_verification" -> conditions.append(" and o.order_status = 1 and o.verify_status = 0"); |
| | | // 待核销要排除退款中/已退款的订单:它们已经离开可核销范围,点核销只会被资金边界拒绝。 |
| | | case "pending_verification" -> conditions.append(" and o.order_status = 1 and o.verify_status = 0 and o.refund_status = 0"); |
| | | case "pending_payment" -> conditions.append(" and o.order_status = 0"); |
| | | case "completed" -> conditions.append(" and o.order_status = 1 and o.verify_status = 1"); |
| | | // 离开支付流程的订单:未支付被取消,或已经退款(含退款中)。 |
| | | case "closed" -> conditions.append(" and (o.order_status = 2 or o.refund_status <> 0)"); |
| | | default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "订单状态无效"); |
| | | } |
| | | } |
| | |
| | | private Map<String, Object> toListItem(Map<String, Object> order) { |
| | | Map<String, Object> result = new LinkedHashMap<>(order); |
| | | String category = category(String.valueOf(order.get("orderType"))); |
| | | String status = status(number(order.get("orderStatus")), number(order.get("verifyStatus"))); |
| | | Long refundStatus = number(order.get("refundStatus")); |
| | | String status = status(number(order.get("orderStatus")), number(order.get("verifyStatus")), refundStatus); |
| | | result.put("category", category); |
| | | result.put("status", status); |
| | | result.put("statusText", statusText(status)); |
| | | result.put("statusText", statusText(status, refundStatus)); |
| | | result.put("customerName", displayName(order.get("talentName"), number(order.get("gender")))); |
| | | result.put("customerMobile", maskMobile(String.valueOf(order.get("talentMobile")))); |
| | | result.put("title", orderTitle(order, category)); |
| | |
| | | return "group"; |
| | | } |
| | | |
| | | private String status(Long orderStatus, Long verifyStatus) { |
| | | private String status(Long orderStatus, Long verifyStatus, Long refundStatus) { |
| | | if (orderStatus != null && orderStatus == 0) return "pending_payment"; |
| | | // 退款中/已退款、已取消的订单都已经离开「待核销」:钱退给了人才,或者订单根本没付过钱。 |
| | | // 把它们兜底成「待核销」会让商户以为还有一单要核销,点核销又会被资金边界拒绝。 |
| | | if (refundStatus != null && refundStatus != 0) return "refunded"; |
| | | if (orderStatus != null && orderStatus == 2) return "cancelled"; |
| | | if (verifyStatus != null && verifyStatus == 1) return "completed"; |
| | | return "pending_verification"; |
| | | } |
| | | |
| | | private String statusText(String status) { |
| | | private String statusText(String status, Long refundStatus) { |
| | | if ("pending_payment".equals(status)) return "待支付"; |
| | | if ("completed".equals(status)) return "已完成"; |
| | | if ("cancelled".equals(status)) return "已取消"; |
| | | if ("refunded".equals(status)) return refundStatus != null && refundStatus == 1 ? "退款中" : "已退款"; |
| | | return "待核销"; |
| | | } |
| | | |
| | |
| | | import com.talentcard.server.common.ErrorCode; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Propagation; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | | import org.springframework.transaction.support.TransactionTemplate; |
| | | |
| | | import java.math.BigDecimal; |
| | |
| | | return detail(refundNo); |
| | | } |
| | | |
| | | /** 刚提交的退款通道必然还在处理,留一点时间再去问,避免用户一打开页面就打通道。 */ |
| | | private static final int SUBMISSION_GRACE_SECONDS = 20; |
| | | /** 一轮最多收敛几笔在途退款:正常用户同一时刻只有一笔,列表读路径不该被它拖长。 */ |
| | | public static final int PENDING_CONFIRM_LIMIT = 2; |
| | | |
| | | /** |
| | | * 把这位人才名下还在途的退款各自向通道确认一次。 |
| | | * |
| | | * <p>退款提交时通道多半只回「处理中」,最终结果只有通道知道;退款通知又会遇到和支付通知一样的 |
| | | * 问题——本地或内网环境收不到。于是在读订单/读订单列表之前问一次,把「退款中」尽快收敛成通道的 |
| | | * 最终答案,而不是等对账任务的下一个窗口(最长十分钟)。已经落定的退款连查都不查。 |
| | | * |
| | | * <p>在自己的事务里执行,且必须是调用方事务里的**第一条**语句:调用方的事务要能读到收敛后的状态, |
| | | * 而 MySQL 的一致性快照是在该事务的第一条读语句上定下的。单笔查询失败只跳过这一笔——读路径上的 |
| | | * 尽力收敛,查不到结果不该让订单列表打不开,剩下的交给对账任务和下一次读。 |
| | | */ |
| | | @Transactional(propagation = Propagation.REQUIRES_NEW) |
| | | public int confirmPendingRefunds(Long talentId, int limit) { |
| | | return confirmPendingRefunds("o.talent_id", talentId, limit); |
| | | } |
| | | |
| | | /** 同上,按商户归属收敛:商户端订单列表读之前调用,免得商户看到的退款状态比人才端晚几分钟。 */ |
| | | @Transactional(propagation = Propagation.REQUIRES_NEW) |
| | | public int confirmPendingRefundsOfMerchant(Long merchantId, int limit) { |
| | | return confirmPendingRefunds("o.merchant_id", merchantId, limit); |
| | | } |
| | | |
| | | private int confirmPendingRefunds(String ownerColumn, Long ownerId, int limit) { |
| | | if (ownerId == null) return 0; |
| | | List<Map<String, Object>> pending = rows(""" |
| | | select r.refund_no |
| | | from order_refund r |
| | | join benefit_order o on o.id = r.order_id |
| | | where r.deleted = 0 and r.provider = ? and r.refund_status = 1 |
| | | and o.deleted = 0 and %s = ? |
| | | and r.created_at <= date_sub(now(), interval ? second) |
| | | order by r.id desc |
| | | limit ? |
| | | """.formatted(ownerColumn), PROVIDER, ownerId, SUBMISSION_GRACE_SECONDS, Math.max(1, limit)); |
| | | for (Map<String, Object> row : pending) { |
| | | try { |
| | | query(text(row.get("refundNo"))); |
| | | } catch (RuntimeException ignored) { |
| | | // 这一笔这次问不到,保持在途;对账任务与下一次读会再来。 |
| | | } |
| | | } |
| | | return pending.size(); |
| | | } |
| | | |
| | | public Map<String, Object> detail(String refundNo) { |
| | | List<Map<String, Object>> rows = rows(""" |
| | | select r.id, r.order_id, r.refund_no, r.order_no, r.order_type, r.paid_amount, r.refund_amount, |
| | |
| | | |
| | | private void markSucceeded(PreparedRefund prepared, ProviderResult result) { |
| | | transactions.executeWithoutResult(status -> { |
| | | // 通道退款流水号只在发起退款的那次回执里有;退款查询的回执不带它。这里必须保留已有的号, |
| | | // 否则查单确认会把唯一的通道凭证覆盖成空,退款排查和对账就失去定位依据。 |
| | | jdbc.update(""" |
| | | update order_refund |
| | | set refund_status = 2, provider_refund_no = ?, provider_response_code = ?, provider_response_message = ?, refund_time = now() |
| | | set refund_status = 2, provider_refund_no = coalesce(nullif(?, ''), provider_refund_no), |
| | | provider_response_code = ?, provider_response_message = ?, refund_time = now() |
| | | where refund_no = ? and refund_status = 1 |
| | | """, result.providerNo(), result.code(), result.message(), prepared.refundNo()); |
| | | jdbc.update(""" |
| | |
| | | import com.talentcard.server.business.DatabaseRows; |
| | | import com.talentcard.server.business.order.OrderRefundRequest; |
| | | import com.talentcard.server.business.order.OrderService; |
| | | import com.talentcard.server.business.payment.HuifuRefundService; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import com.talentcard.server.common.ErrorCode; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | |
| | | |
| | | @Service |
| | | public class PendingUsePortalService { |
| | | private final JdbcTemplate jdbc; private final OrderService orderService; |
| | | public PendingUsePortalService(JdbcTemplate jdbc, OrderService orderService) { this.jdbc = jdbc; this.orderService = orderService; } |
| | | @Transactional(readOnly = true) public Map<String,Object> detail(String orderNo, Long talentId) { return view(find(orderNo, talentId)); } |
| | | private final JdbcTemplate jdbc; private final OrderService orderService; private final HuifuRefundService refundService; |
| | | public PendingUsePortalService(JdbcTemplate jdbc, OrderService orderService, HuifuRefundService refundService) { |
| | | this.jdbc = jdbc; this.orderService = orderService; this.refundService = refundService; |
| | | } |
| | | @Transactional(readOnly = true) public Map<String,Object> detail(String orderNo, Long talentId) { |
| | | // 退款通知和支付通知一样可能到不了,而退款到不到最终结果只有通道知道。先收敛在途的退款,再读订单: |
| | | // 这一步必须排在本事务的第一条读语句之前,否则读到的是收敛前的旧快照。 |
| | | refundService.confirmPendingRefunds(talentId, HuifuRefundService.PENDING_CONFIRM_LIMIT); |
| | | return view(find(orderNo, talentId)); |
| | | } |
| | | @Transactional(readOnly = true) public Map<String,Object> completedDetail(String orderNo, Long talentId) { |
| | | Map<String,Object> result = detail(orderNo, talentId); |
| | | if (!Boolean.TRUE.equals(result.get("verified"))) throw new BusinessException(ErrorCode.CONFLICT, "订单尚未完成核销"); |
| | |
| | | } |
| | | @Transactional(readOnly = true) public Map<String,Object> cancelledDetail(String orderNo, Long talentId) { |
| | | Map<String,Object> result = detail(orderNo, talentId); |
| | | if (number(result.get("orderStatus")) != 2) throw new BusinessException(ErrorCode.CONFLICT, "订单当前未取消"); |
| | | // 退款中的订单也要能打开这一页:退款已经受理就不能再核销,「退款中」是它此刻的真实状态。 |
| | | if (number(result.get("orderStatus")) != 2 && number(result.get("refundStatus")) == 0) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单当前未取消"); |
| | | } |
| | | long refundStatus = number(result.get("refundStatus")); |
| | | result.put("cancelTime", result.get("refundTime") == null ? result.get("updatedAt") : result.get("refundTime")); |
| | | result.put("cancelSource", refundStatus == 0 ? "订单取消" : "退款处理"); |
| | |
| | | return result; |
| | | } |
| | | @Transactional public Map<String,Object> issueCode(String orderNo, Long talentId) { |
| | | Map<String,Object> order = find(orderNo, talentId); if (number(order.get("orderStatus")) != 1 || number(order.get("verifyStatus")) == 1 || "STORE_CONSUME".equals(String.valueOf(order.get("orderType")))) throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可核销"); |
| | | Map<String,Object> order = find(orderNo, talentId); |
| | | // 退款中或已退款的订单不能再发核销码:钱要退给人才,商户那边也核销不了,发了只会让双方白跑一趟。 |
| | | if (number(order.get("orderStatus")) != 1 || number(order.get("verifyStatus")) == 1 || number(order.get("refundStatus")) != 0 || "STORE_CONSUME".equals(String.valueOf(order.get("orderType")))) throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可核销"); |
| | | jdbc.update("update order_dynamic_verification_code set status = 2 where order_id = ? and status = 0", order.get("id")); |
| | | String token = UUID.randomUUID().toString().replace("-", ""); String display = String.format("%06d", new Random().nextInt(1_000_000)); Instant expires = Instant.now().plusSeconds(60); |
| | | jdbc.update("insert into order_dynamic_verification_code(code_token, display_code, order_id, talent_id, expires_at, status) values (?, ?, ?, ?, ?, 0)", token, display, order.get("id"), talentId, Timestamp.from(expires)); |
| | |
| | | |
| | | import com.talentcard.server.business.DatabaseRows; |
| | | import com.talentcard.server.business.PageResult; |
| | | import com.talentcard.server.business.payment.HuifuRefundService; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import com.talentcard.server.common.ErrorCode; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | |
| | | ); |
| | | |
| | | private final JdbcTemplate jdbcTemplate; |
| | | private final HuifuRefundService refundService; |
| | | |
| | | public UsageRecordPortalService(JdbcTemplate jdbcTemplate) { |
| | | public UsageRecordPortalService(JdbcTemplate jdbcTemplate, HuifuRefundService refundService) { |
| | | this.jdbcTemplate = jdbcTemplate; |
| | | this.refundService = refundService; |
| | | } |
| | | |
| | | @Transactional(readOnly = true) |
| | |
| | | if (status != null && !status.isBlank() && !SUPPORTED_STATUSES.contains(status)) { |
| | | throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的记录状态"); |
| | | } |
| | | // 退款到不到最终结果只有通道知道,退款通知又会遇到和支付通知一样的问题(本地/内网环境收不到)。 |
| | | // 所以在读列表之前先把在途退款收敛一次——否则刚退完款的订单会一直显示成「待核销」。这一步必须排在 |
| | | // 本事务的第一条读语句之前,否则列表读到的是收敛前的旧快照。 |
| | | refundService.confirmPendingRefunds(talentId, HuifuRefundService.PENDING_CONFIRM_LIMIT); |
| | | Integer talentCount = jdbcTemplate.queryForObject( |
| | | "select count(*) from talent_user where id = ? and deleted = 0", Integer.class, talentId); |
| | | if (talentCount == null || talentCount == 0) { |
| | |
| | | o.created_at as occurred_at, |
| | | case |
| | | when o.order_status = 0 then 'pending_payment' |
| | | when o.order_status = 2 then 'cancelled' |
| | | when o.order_status = 2 or o.refund_status <> 0 then 'cancelled' |
| | | when o.verify_status = 1 then 'completed' |
| | | else 'pending_verification' |
| | | end as status, |
| | |
| | | .containsEntry("subsidyAmount", 14799L); |
| | | } |
| | | |
| | | @Test |
| | | void aCancelledOrderIsShownAsCancelledInsteadOfWaitingToBeVerified() { |
| | | String orderNo = insertOrder("GROUP_BUY", 2, 0, LocalDateTime.now()); |
| | | |
| | | assertThat(recordOf(list("group", "all"), orderNo)) |
| | | .containsEntry("status", "cancelled") |
| | | .containsEntry("statusText", "已取消"); |
| | | assertThat(orderNos(list("group", "pending_verification"))) |
| | | .as("已取消的订单不是待核销:商户照它去核销只会被资金边界拒绝") |
| | | .doesNotContain(orderNo); |
| | | assertThat(orderNos(list("group", "closed"))).contains(orderNo); |
| | | } |
| | | |
| | | @Test |
| | | void aRefundedOrderIsShownAsRefundedAndStopsWaitingToBeVerified() { |
| | | String orderNo = insertOrder("GROUP_BUY", 1, 0, LocalDateTime.now()); |
| | | jdbc.update("update benefit_order set refund_status = 2 where order_no = ?", orderNo); |
| | | |
| | | assertThat(recordOf(list("group", "all"), orderNo)) |
| | | .containsEntry("status", "refunded") |
| | | .containsEntry("statusText", "已退款"); |
| | | assertThat(orderNos(list("group", "pending_verification"))).doesNotContain(orderNo); |
| | | assertThat(orderNos(list("group", "closed"))).contains(orderNo); |
| | | } |
| | | |
| | | @Test |
| | | void aRefundInFlightIsShownAsRefunding() { |
| | | String orderNo = insertOrder("GROUP_BUY", 1, 0, LocalDateTime.now()); |
| | | jdbc.update("update benefit_order set refund_status = 1 where order_no = ?", orderNo); |
| | | |
| | | assertThat(recordOf(list("group", "all"), orderNo)).containsEntry("statusText", "退款中"); |
| | | assertThat(orderNos(list("group", "pending_verification"))).doesNotContain(orderNo); |
| | | } |
| | | |
| | | @Test |
| | | void theOrderDetailCarriesTheSameClosedState() { |
| | | String orderNo = insertOrder("GROUP_BUY", 1, 0, LocalDateTime.now()); |
| | | jdbc.update("update benefit_order set refund_status = 1 where order_no = ?", orderNo); |
| | | |
| | | assertThat(merchantWorkService.order(orderNo, merchantId, storeId)) |
| | | .containsEntry("status", "refunded") |
| | | .containsEntry("statusText", "退款中"); |
| | | assertThat(merchantWorkService.order(insertOrder("GROUP_BUY", 2, 0, LocalDateTime.now()), merchantId, storeId)) |
| | | .containsEntry("status", "cancelled"); |
| | | } |
| | | |
| | | private PageResult<Map<String, Object>> list(String category, String status) { |
| | | return merchantWorkService.orders(merchantId, storeId, 1, 50, new MerchantOrderQuery(category, status, null, null)); |
| | | } |
| | | |
| | | private Map<String, Object> recordOf(PageResult<Map<String, Object>> page, String orderNo) { |
| | | return page.records().stream() |
| | | .filter(record -> orderNo.equals(String.valueOf(record.get("orderNo")))) |
| | | .findFirst() |
| | | .orElseThrow(() -> new AssertionError("Order " + orderNo + " is missing from the merchant order list")); |
| | | } |
| | | |
| | | private java.util.List<String> orderNos(PageResult<Map<String, Object>> page) { |
| | | return page.records().stream().map(record -> String.valueOf(record.get("orderNo"))).toList(); |
| | | } |
| | | |
| | | private String insertOrder(String type, int orderStatus, int verifyStatus, LocalDateTime createdAt) { |
| | | String orderNo = "ORDER-" + System.nanoTime(); |
| | | jdbc.update(""" |
| New file |
| | |
| | | package com.talentcard.server.business.payment; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.Mockito.never; |
| | | import static org.mockito.Mockito.verify; |
| | | |
| | | import java.util.Map; |
| | | |
| | | import com.talentcard.server.business.PageResult; |
| | | import com.talentcard.server.business.payment.huifu.HuifuRefundGateway; |
| | | import com.talentcard.server.business.portal.PendingUsePortalService; |
| | | import com.talentcard.server.business.portal.UsageRecordPortalService; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.boot.test.context.SpringBootTest; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.test.context.ActiveProfiles; |
| | | import org.springframework.test.context.bean.override.mockito.MockitoBean; |
| | | |
| | | /** |
| | | * Seam: how the talent app reads an order that was cancelled before it was ever paid. |
| | | * |
| | | * <p>「已取消」covers two different stories and they must not be merged: an order the talent |
| | | * abandoned while it was still unpaid(订单取消,无需退款)and an order whose money was given back |
| | | * (退款处理,退款中 / 退款成功 / 退款失败). The first one never involved money, so it asks the |
| | | * provider nothing and reads as a plain cancellation; the refund dimension only adds a reason to the |
| | | * same state, it does not take the state over. |
| | | */ |
| | | @SpringBootTest |
| | | @ActiveProfiles("test") |
| | | class CancelledOrderStateTests { |
| | | |
| | | @Autowired |
| | | private JdbcTemplate jdbc; |
| | | |
| | | @Autowired |
| | | private UsageRecordPortalService usageRecordPortalService; |
| | | |
| | | @Autowired |
| | | private PendingUsePortalService pendingUsePortalService; |
| | | |
| | | @MockitoBean |
| | | private HuifuRefundGateway refundGateway; |
| | | |
| | | private Long talentId; |
| | | private String suffix; |
| | | |
| | | @BeforeEach |
| | | void prepareData() { |
| | | suffix = String.valueOf(System.nanoTime()); |
| | | jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)", |
| | | "cancelled-order-openid-" + suffix, "Cancelled order user", "13900000005"); |
| | | talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class); |
| | | } |
| | | |
| | | @Test |
| | | void anOrderCancelledBeforePaymentIsStillListedAsCancelled() { |
| | | String orderNo = cancelledUnpaidOrder(); |
| | | |
| | | assertThat(recordOf(orderNo)).containsEntry("status", "cancelled"); |
| | | } |
| | | |
| | | @Test |
| | | void anOrderCancelledBeforePaymentReadsAsACancellationThatNeedsNoRefund() { |
| | | String orderNo = cancelledUnpaidOrder(); |
| | | |
| | | Map<String, Object> detail = pendingUsePortalService.cancelledDetail(orderNo, talentId); |
| | | |
| | | assertThat(detail) |
| | | .containsEntry("cancelSource", "订单取消") |
| | | .containsEntry("refundStatusText", "无需退款"); |
| | | assertThat(((Number) detail.get("actualPaidAmount")).longValue()) |
| | | .as("没付过钱就不该显示退过钱") |
| | | .isZero(); |
| | | verify(refundGateway, never()).queryRefund(any(), any()); |
| | | } |
| | | |
| | | /** An order the talent cancelled while it was still unpaid: no payment, no refund. */ |
| | | private String cancelledUnpaidOrder() { |
| | | String orderNo = "CANCELLED-UNPAID-" + suffix; |
| | | jdbc.update(""" |
| | | insert into benefit_order(order_no, talent_id, order_type, order_amount, base_pay_amount, pay_amount, |
| | | order_status, verify_status, product_name_snapshot) |
| | | values (?, ?, 'GROUP_BUY', 2000, 2000, 2000, 2, 0, '取消测试订单') |
| | | """, orderNo, talentId); |
| | | return orderNo; |
| | | } |
| | | |
| | | private Map<String, Object> recordOf(String orderNo) { |
| | | PageResult<Map<String, Object>> page = usageRecordPortalService.list(talentId, null, 1, 50); |
| | | return page.records().stream() |
| | | .filter(record -> orderNo.equals(String.valueOf(record.get("businessId")))) |
| | | .findFirst() |
| | | .orElseThrow(() -> new AssertionError("Order " + orderNo + " is missing from the talent order list")); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.talentcard.server.business.payment; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | import static org.assertj.core.api.Assertions.assertThatThrownBy; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.Mockito.never; |
| | | import static org.mockito.Mockito.verify; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | import java.util.Map; |
| | | |
| | | import com.talentcard.server.business.PageResult; |
| | | import com.talentcard.server.business.merchant.MerchantOrderQuery; |
| | | import com.talentcard.server.business.merchant.MerchantWorkService; |
| | | import com.talentcard.server.business.payment.huifu.HuifuRefundGateway; |
| | | import com.talentcard.server.business.portal.PendingUsePortalService; |
| | | import com.talentcard.server.business.portal.UsageRecordPortalService; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.boot.test.context.SpringBootTest; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.test.context.ActiveProfiles; |
| | | import org.springframework.test.context.bean.override.mockito.MockitoBean; |
| | | |
| | | /** |
| | | * Seam: a submitted refund is not finished until the provider says so, and the provider's |
| | | * notification cannot be relied on. Reading the order must therefore ask the provider about a refund |
| | | * that is still in flight, so the order stops looking verifiable the moment money goes back. |
| | | * |
| | | * <p>A refund in flight is never shown as "waiting to be verified": either the provider confirms it |
| | | * (the order leaves the verifiable set for good) or it is still in flight (it stays unverifiable |
| | | * until the provider decides). Only the provider's answer may change that. |
| | | */ |
| | | @SpringBootTest |
| | | @ActiveProfiles("test") |
| | | class PendingRefundConfirmationTests { |
| | | |
| | | @Autowired |
| | | private JdbcTemplate jdbc; |
| | | |
| | | @Autowired |
| | | private PendingUsePortalService pendingUsePortalService; |
| | | |
| | | @Autowired |
| | | private UsageRecordPortalService usageRecordPortalService; |
| | | |
| | | @Autowired |
| | | private MerchantWorkService merchantWorkService; |
| | | |
| | | @MockitoBean |
| | | private HuifuRefundGateway refundGateway; |
| | | |
| | | private Long talentId; |
| | | private Long merchantId; |
| | | private String huifuId; |
| | | private String suffix; |
| | | private long sequence; |
| | | |
| | | @BeforeEach |
| | | void prepareData() { |
| | | suffix = String.valueOf(System.nanoTime()); |
| | | jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)", |
| | | "pending-refund-openid-" + suffix, "Pending refund user", "13900000004"); |
| | | talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class); |
| | | jdbc.update("insert into merchant(merchant_name, contact_mobile, address, status, deleted) values (?, ?, ?, 1, 0)", |
| | | "Pending refund merchant " + suffix, "08120000003", "Test address"); |
| | | merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class); |
| | | huifuId = "H" + suffix.substring(Math.max(0, suffix.length() - 8)); |
| | | sequence = 0; |
| | | } |
| | | |
| | | @Test |
| | | void openingAnOrderConvergesTheRefundThatIsStillInFlight() { |
| | | RefundingOrder order = refundingOrder(1); |
| | | providerSettlesTheRefund(); |
| | | |
| | | Map<String, Object> detail = pendingUsePortalService.detail(order.orderNo(), talentId); |
| | | |
| | | verify(refundGateway).queryRefund(any(), any()); |
| | | assertThat(((Number) detail.get("refundStatus")).intValue()).isEqualTo(2); |
| | | assertThat(jdbc.queryForMap("select order_status, refund_status from benefit_order where order_no = ?", order.orderNo())) |
| | | .containsEntry("order_status", 2) |
| | | .containsEntry("refund_status", 2); |
| | | assertThat(jdbc.queryForObject("select refund_status from order_refund where refund_no = ?", Integer.class, order.refundNo())) |
| | | .isEqualTo(2); |
| | | } |
| | | |
| | | @Test |
| | | void theRecordListConvergesTheRefundInsteadOfLeavingTheOrderWaitingToBeVerified() { |
| | | RefundingOrder order = refundingOrder(1); |
| | | providerSettlesTheRefund(); |
| | | |
| | | Map<String, Object> record = recordOf(order.orderNo()); |
| | | |
| | | verify(refundGateway).queryRefund(any(), any()); |
| | | assertThat(record).containsEntry("status", "cancelled"); |
| | | } |
| | | |
| | | @Test |
| | | void anOrderWhoseRefundIsStillInFlightIsNotWaitingToBeVerifiedEither() { |
| | | RefundingOrder order = refundingOrder(1); |
| | | when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "P", "resp_code", "00000100", "sub_resp_desc", "交易处理中", "hf_seq_id", "HF-PENDING-" + suffix))); |
| | | |
| | | assertThat(recordOf(order.orderNo())).containsEntry("status", "cancelled"); |
| | | assertThat(jdbc.queryForObject("select refund_status from order_refund where refund_no = ?", Integer.class, order.refundNo())) |
| | | .as("通道还在处理就不能自行判定退款成功") |
| | | .isEqualTo(1); |
| | | assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, order.orderNo())) |
| | | .isEqualTo(1); |
| | | } |
| | | |
| | | @Test |
| | | void anAlreadyRefundedOrderIsNotWaitingToBeVerifiedAndAsksTheProviderNothing() { |
| | | RefundingOrder order = refundingOrder(2); |
| | | |
| | | assertThat(recordOf(order.orderNo())).containsEntry("status", "cancelled"); |
| | | verify(refundGateway, never()).queryRefund(any(), any()); |
| | | } |
| | | |
| | | @Test |
| | | void anOrderWithARefundInFlightOpensOnTheCancelledDetail() { |
| | | RefundingOrder order = refundingOrder(1); |
| | | when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "P", "resp_code", "00000100", "sub_resp_desc", "交易处理中", "hf_seq_id", "HF-PENDING-" + suffix))); |
| | | |
| | | Map<String, Object> detail = pendingUsePortalService.cancelledDetail(order.orderNo(), talentId); |
| | | |
| | | assertThat(detail).containsEntry("refundStatusText", "退款中"); |
| | | assertThat(detail).containsEntry("cancelSource", "退款处理"); |
| | | } |
| | | |
| | | @Test |
| | | void anOrderWithARefundStillInFlightDoesNotIssueAVerificationCode() { |
| | | RefundingOrder order = refundingOrder(1); |
| | | when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "P", "resp_code", "00000100", "sub_resp_desc", "交易处理中", "hf_seq_id", "HF-PENDING-" + suffix))); |
| | | |
| | | assertThatThrownBy(() -> pendingUsePortalService.issueCode(order.orderNo(), talentId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("不可核销"); |
| | | } |
| | | |
| | | @Test |
| | | void aRefundConfirmedByAQueryKeepsTheProviderReferenceItWasGiven() { |
| | | RefundingOrder order = refundingOrder(1); |
| | | // 退款查询回执里没有通道退款流水号(只有发起退款时那次回执有)。 |
| | | when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "S", "resp_code", "00000000", "sub_resp_desc", "查询成功"))); |
| | | |
| | | pendingUsePortalService.detail(order.orderNo(), talentId); |
| | | |
| | | assertThat(jdbc.queryForObject("select provider_refund_no from order_refund where refund_no = ?", String.class, order.refundNo())) |
| | | .as("查单确认不能把发起退款时拿到的通道退款流水号覆盖成空") |
| | | .isEqualTo(order.providerRefundNo()); |
| | | } |
| | | |
| | | @Test |
| | | void theMerchantOrderListConvergesTheRefundTooInsteadOfShowingItAsRefunding() { |
| | | RefundingOrder order = refundingOrder(1); |
| | | providerSettlesTheRefund(); |
| | | |
| | | Map<String, Object> record = merchantRecordOf(order.orderNo()); |
| | | |
| | | verify(refundGateway).queryRefund(any(), any()); |
| | | assertThat(record) |
| | | .as("商户看到的退款状态不该比人才端晚几分钟") |
| | | .containsEntry("status", "refunded") |
| | | .containsEntry("statusText", "已退款"); |
| | | } |
| | | |
| | | /** A paid order whose refund was submitted and has no final provider answer yet. */ |
| | | private RefundingOrder refundingOrder(int refundStatus) { |
| | | String orderNo = "REFUND-" + suffix + "-" + (++sequence); |
| | | String refundNo = "R-REFUND-" + suffix + "-" + sequence; |
| | | String paymentNo = "P-REFUND-" + suffix + "-" + sequence; |
| | | String providerRefundNo = "HF-REFUND-SUBMIT-" + sequence; |
| | | jdbc.update(""" |
| | | insert into benefit_order(order_no, talent_id, merchant_id, order_type, order_amount, base_pay_amount, |
| | | pay_amount, order_status, verify_status, refund_status, price_version, |
| | | collect_huifu_id, product_name_snapshot) |
| | | values (?, ?, ?, 'GROUP_BUY', 2000, 2000, 2000, 1, 0, ?, 1, ?, '退款测试订单') |
| | | """, orderNo, talentId, merchantId, refundStatus, huifuId); |
| | | Long orderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo); |
| | | jdbc.update(""" |
| | | insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id, |
| | | provider_huifu_id, amount, status, idempotency_key, paid_at) |
| | | values (?, 'HUIFU', ?, ?, ?, ?, ?, 2000, 'SUCCEEDED', ?, now()) |
| | | """, paymentNo, orderId, orderNo, talentId, merchantId, huifuId, "refund-payment-" + paymentNo); |
| | | jdbc.update(""" |
| | | insert into order_refund(refund_no, order_id, order_no, talent_id, merchant_id, order_type, |
| | | paid_amount, refund_amount, refundable_amount, refund_status, |
| | | provider, provider_huifu_id, provider_refund_no, |
| | | provider_original_req_seq_id, provider_original_req_date, |
| | | reason, operator_name, created_at) |
| | | values (?, ?, ?, ?, ?, 'GROUP_BUY', 2000, 2000, 0, ?, 'HUIFU', ?, ?, ?, ?, '用户申请退款', '人才端用户', |
| | | now() - interval 2 minute) |
| | | """, refundNo, orderId, orderNo, talentId, merchantId, refundStatus, huifuId, providerRefundNo, paymentNo, |
| | | java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd").format(java.time.LocalDate.now())); |
| | | return new RefundingOrder(orderNo, refundNo, providerRefundNo); |
| | | } |
| | | |
| | | private void providerSettlesTheRefund() { |
| | | when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "S", "resp_code", "00000000", "sub_resp_desc", "交易成功", "hf_seq_id", "HF-REFUND-" + suffix))); |
| | | } |
| | | |
| | | /** The record the talent's order list serves for this order. */ |
| | | private Map<String, Object> recordOf(String orderNo) { |
| | | PageResult<Map<String, Object>> page = usageRecordPortalService.list(talentId, null, 1, 50); |
| | | return page.records().stream() |
| | | .filter(record -> orderNo.equals(String.valueOf(record.get("businessId")))) |
| | | .findFirst() |
| | | .orElseThrow(() -> new AssertionError("Order " + orderNo + " is missing from the talent order list")); |
| | | } |
| | | |
| | | /** The record the merchant's order list serves for this order. */ |
| | | private Map<String, Object> merchantRecordOf(String orderNo) { |
| | | PageResult<Map<String, Object>> page = merchantWorkService.orders(merchantId, null, 1, 50, |
| | | new MerchantOrderQuery("group", "all", null, null)); |
| | | return page.records().stream() |
| | | .filter(record -> orderNo.equals(String.valueOf(record.get("orderNo")))) |
| | | .findFirst() |
| | | .orElseThrow(() -> new AssertionError("Order " + orderNo + " is missing from the merchant order list")); |
| | | } |
| | | |
| | | private Map<String, Object> providerResponse(Map<String, Object> data) { |
| | | return Map.of("data", data); |
| | | } |
| | | |
| | | private record RefundingOrder(String orderNo, String refundNo, String providerRefundNo) { |
| | | } |
| | | } |