package com.talentcard.server.business.merchant; import java.sql.Timestamp; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.Duration; import java.math.BigDecimal; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import com.talentcard.server.business.BusinessIdGenerator; import com.talentcard.server.business.DatabaseRows; import com.talentcard.server.business.PageResult; 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; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import tools.jackson.core.type.TypeReference; import tools.jackson.databind.ObjectMapper; @Service public class MerchantWorkService { private static final long REVOCATION_HOURS = 3; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final TypeReference>> RIGHTS_TYPE = new TypeReference<>() {}; private final JdbcTemplate jdbcTemplate; private final ManualConsumptionService manualConsumptionService; private final OrderFundBoundaryService orderFundBoundary; private final com.talentcard.server.business.order.OrderVerificationService orderVerificationService; private final HuifuRefundService refundService; private final PaymentFundConfirmationService fundConfirmation; public MerchantWorkService(JdbcTemplate jdbcTemplate, ManualConsumptionService manualConsumptionService, OrderFundBoundaryService orderFundBoundary, com.talentcard.server.business.order.OrderVerificationService orderVerificationService, HuifuRefundService refundService, PaymentFundConfirmationService fundConfirmation) { this.jdbcTemplate = jdbcTemplate; this.manualConsumptionService = manualConsumptionService; this.orderFundBoundary = orderFundBoundary; this.orderVerificationService = orderVerificationService; this.refundService = refundService; this.fundConfirmation = fundConfirmation; } @Transactional(readOnly = true) public PageResult> orders(Long merchantId, Long storeId, int page, int size, MerchantOrderQuery query) { if (query != null && "stay".equals(normalize(query.category()))) { return freeStayOrders(merchantId, storeId, page, size, query); } // 观影券(票包领取记录)在商户眼里也是「团购」的一份流水,和 benefit_order 混排分页。 if (query != null && "group".equals(normalize(query.category()))) { return groupOrders(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); String conditions = " where o.deleted = 0 and o.merchant_id = ? and (o.store_id is null or o.store_id = ?)" + filters.conditions(); List arguments = new ArrayList<>(); arguments.add(merchantId); arguments.add(storeId); arguments.addAll(filters.arguments()); String recordSql = """ 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.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 left join benefit b on b.id = o.benefit_id left join (select order_id, max(verify_time) verify_time from verify_record where status = 1 group by order_id) verified on verified.order_id = o.id """ + conditions + " order by o.created_at desc, o.id desc limit ? offset ?"; List recordArguments = new ArrayList<>(arguments); recordArguments.add(safeSize); recordArguments.add((safePage - 1) * safeSize); List> records = query(recordSql, recordArguments.toArray()).stream().map(this::toListItem).toList(); Long total = jdbcTemplate.queryForObject("select count(*) from benefit_order o" + conditions, Long.class, arguments.toArray()); return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total); } public PageResult> orders(Long merchantId, Long storeId, int page, int size) { return orders(merchantId, storeId, page, size, new MerchantOrderQuery(null, null, null, null)); } @Transactional(readOnly = true) public Map order(String orderNo, Long merchantId, Long storeId) { List> rows = query(""" select o.id, o.order_no, o.talent_id, tu.name as talent_name, tu.mobile as talent_mobile, tu.gender, o.card_id, tc.card_no, coalesce(nullif(o.card_level_snapshot, ''), tc.card_level) as card_level, o.benefit_id, b.benefit_name, o.merchant_id, m.merchant_name, m.merchant_rights_config, o.product_id, o.product_name_snapshot, p.rights_config, p.cover_url as product_cover_url, m.cover_url as merchant_cover_url, 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.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 left join talent_card tc on tc.id = o.card_id left join benefit b on b.id = o.benefit_id left join merchant m on m.id = o.merchant_id left join merchant_product p on p.id = o.product_id and p.deleted = 0 left join verify_record vr on vr.order_id = o.id and vr.status = 1 where o.deleted = 0 and o.order_no = ? and o.merchant_id = ? and (o.store_id is null or o.store_id = ?) """, orderNo, merchantId, storeId); if (rows.isEmpty()) { List> freeStayRows = query(""" select r.id, r.verify_no as order_no, r.talent_id, tu.name as talent_name, tu.mobile as talent_mobile, tu.gender, r.card_id, tc.card_no, tc.card_level, r.merchant_id, m.merchant_name, m.merchant_rights_config, m.cover_url as merchant_cover_url, '免费入住' 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, 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 left join talent_card tc on tc.id = r.card_id left join merchant m on m.id = r.merchant_id where r.verify_no = ? and r.verify_type = 'HOTEL_FREE' and r.status = 1 and r.merchant_id = ? and (r.store_id is null or r.store_id = ?) limit 1 """, orderNo, merchantId, storeId); if (freeStayRows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "订单不存在"); return enrichOrder(freeStayRows.get(0)); } return enrichOrder(rows.get(0)); } private PageResult> freeStayOrders(Long merchantId, Long storeId, int page, int size, MerchantOrderQuery query) { int safePage = Math.max(page, 1); int safeSize = Math.min(Math.max(size, 1), 100); StringBuilder condition = new StringBuilder(" where r.verify_type = 'HOTEL_FREE' and r.status = 1 and r.merchant_id = ? and (r.store_id is null or r.store_id = ?)"); List arguments = new ArrayList<>(); arguments.add(merchantId); arguments.add(storeId); LocalDate startDate = date(query.startDate(), "开始日期"); LocalDate endDate = date(query.endDate(), "结束日期"); if (startDate != null && endDate != null && startDate.isAfter(endDate)) { throw new BusinessException(ErrorCode.BAD_REQUEST, "开始日期不能晚于结束日期"); } if (startDate != null) { condition.append(" and r.verify_time >= ?"); arguments.add(Timestamp.valueOf(startDate.atStartOfDay())); } if (endDate != null) { condition.append(" and r.verify_time < ?"); arguments.add(Timestamp.valueOf(endDate.plusDays(1).atStartOfDay())); } String select = """ select r.id, r.verify_no as order_no, r.talent_id, tu.name as talent_name, tu.mobile as talent_mobile, tu.gender, 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, 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 left join talent_card tc on tc.id = r.card_id left join merchant m on m.id = r.merchant_id """ + condition + " order by r.verify_time desc, r.id desc limit ? offset ?"; List recordArguments = new ArrayList<>(arguments); recordArguments.add(safeSize); recordArguments.add((safePage - 1) * safeSize); List> records = query(select, recordArguments.toArray()).stream().map(this::toListItem).toList(); Long total = jdbcTemplate.queryForObject("select count(*) from verify_record r" + condition, Long.class, arguments.toArray()); return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total); } /** * 团购列表:普通团购订单与观影券(票包领取记录)混排。 * *

观影券不是 benefit_order:兑换码由后台票包码池批量发放,领取记录落在 ticket_package_claim, * 只有核销时才写 benefit_verify_record。但商户在「团购」里看到的应该是一份按时间倒序的流水, * 所以这里把两类记录并成一张表再统一分页,避免前端拿到两段各自分页的数据拼不齐。 */ @Transactional(readOnly = true) private PageResult> groupOrders(Long merchantId, Long storeId, int page, int size, MerchantOrderQuery 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); // 观影券只有已发放/已使用两种有效状态,退款、待支付这些团购态在它身上不存在,按状态筛成空集。 String claimStatusCondition = switch (normalize(query == null ? null : query.status())) { case "", "all" -> ""; case "pending_verification" -> " and cl.status = 'ISSUED'"; case "completed" -> " and cl.status = 'USED'"; case "pending_payment" -> " and 1 = 0"; case "closed" -> " and cl.status in ('EXPIRED', 'CANCELLED')"; default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "订单状态无效"); }; LocalDate startDate = date(query == null ? null : query.startDate(), "开始日期"); LocalDate endDate = date(query == null ? null : query.endDate(), "结束日期"); StringBuilder claimConditions = new StringBuilder(claimStatusCondition); List claimArguments = new ArrayList<>(); claimArguments.add(merchantId); // 时间筛选沿用「下单时间」口径:观影券的下单时间就是领取时间。 if (startDate != null) { claimConditions.append(" and cl.created_at >= ?"); claimArguments.add(Timestamp.valueOf(startDate.atStartOfDay())); } if (endDate != null) { claimConditions.append(" and cl.created_at < ?"); claimArguments.add(Timestamp.valueOf(endDate.plusDays(1).atStartOfDay())); } String orderSource = """ select cast(o.id as char) as id, o.order_no as order_no, 'GROUP_BUY' as order_type, tu.name as talent_name, tu.mobile as talent_mobile, tu.gender, coalesce(nullif(o.card_level_snapshot, ''), tc.card_level) as card_level, o.product_name_snapshot, b.benefit_name, o.order_amount, o.subsidy_amount, o.pay_amount, o.order_status, o.verify_status, o.refund_status, o.created_at, verified.verify_time, '' as source_type, null as claim_id, o.created_at as sort_time, 0 as source_rank, o.id as sort_seq 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 left join benefit b on b.id = o.benefit_id left join (select order_id, max(verify_time) verify_time from verify_record where status = 1 group by order_id) verified on verified.order_id = o.id where o.deleted = 0 and o.merchant_id = ? and (o.store_id is null or o.store_id = ?) """ + filters.conditions(); String claimSource = """ select concat('TP', cl.id) as id, cl.claim_no as order_no, 'GROUP_BUY' as order_type, cl.talent_name as talent_name, cl.talent_mobile as talent_mobile, tu.gender, cl.card_level as card_level, '观影券1张' as product_name_snapshot, '观影券' as benefit_name, 0 as order_amount, 0 as subsidy_amount, 0 as pay_amount, case when cl.status in ('ISSUED', 'USED') then 1 else 2 end as order_status, case when cl.status = 'USED' then 1 else 0 end as verify_status, 0 as refund_status, cl.created_at, case when cl.status = 'USED' then cl.used_at else null end as verify_time, 'TICKET_PACKAGE' as source_type, cl.id as claim_id, cl.created_at as sort_time, 1 as source_rank, cl.id as sort_seq from ticket_package_claim cl left join talent_user tu on tu.id = cl.talent_id where cl.deleted = 0 and cl.merchant_id = ? """ + claimConditions; String recordSql = """ select t.id, t.order_no, t.order_type, t.talent_name, t.talent_mobile, t.gender, t.card_level, t.product_name_snapshot, t.benefit_name, t.order_amount, t.subsidy_amount, t.pay_amount, t.order_status, t.verify_status, t.refund_status, t.created_at, t.verify_time, t.source_type, t.claim_id from ( """ + orderSource + "\n union all \n" + claimSource + """ ) t order by t.sort_time desc, t.source_rank asc, t.sort_seq desc limit ? offset ? """; List recordArguments = new ArrayList<>(); recordArguments.add(merchantId); recordArguments.add(storeId); recordArguments.addAll(filters.arguments()); recordArguments.addAll(claimArguments); recordArguments.add(safeSize); recordArguments.add((safePage - 1) * safeSize); List> records = query(recordSql, recordArguments.toArray()).stream().map(this::toListItem).toList(); String countSql = "select (select count(*) from benefit_order o where o.deleted = 0 and o.merchant_id = ? and (o.store_id is null or o.store_id = ?)" + filters.conditions() + ") + (select count(*) from ticket_package_claim cl where cl.deleted = 0 and cl.merchant_id = ?" + claimConditions + ")"; List countArguments = new ArrayList<>(); countArguments.add(merchantId); countArguments.add(storeId); countArguments.addAll(filters.arguments()); countArguments.addAll(claimArguments); Long total = jdbcTemplate.queryForObject(countSql, Long.class, countArguments.toArray()); return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total); } /** * 观影券领取记录详情:商户在「团购」里点开观影券那一条时看到的信息。 * *

观影券没有 benefit_order,行项目来自 ticket_package_claim;核销时间取 used_at。 */ @Transactional(readOnly = true) public Map ticketPackageClaim(Long claimId, Long merchantId) { if (claimId == null || claimId <= 0) { throw new BusinessException(ErrorCode.BAD_REQUEST, "观影券领取记录ID不能为空"); } List> rows = query(""" select cl.id, cl.claim_no, cl.package_id, p.package_name, cl.merchant_id, m.merchant_name, m.merchant_rights_config, cl.talent_name, cl.talent_mobile, tu.gender, cl.card_level, cl.card_no, cl.status, cl.valid_end, cl.used_at, cl.created_at from ticket_package_claim cl join ticket_package p on p.id = cl.package_id and p.deleted = 0 left join merchant m on m.id = cl.merchant_id and m.deleted = 0 left join talent_user tu on tu.id = cl.talent_id where cl.id = ? and cl.deleted = 0 and cl.merchant_id = ? limit 1 """, claimId, merchantId); if (rows.isEmpty()) { throw new BusinessException(ErrorCode.NOT_FOUND, "观影券领取记录不存在或不属于当前商户"); } Map row = rows.get(0); boolean used = "USED".equalsIgnoreCase(text(row.get("status"))); String cardLevel = text(row.get("cardLevel")); Map result = new LinkedHashMap<>(); result.put("type", "TICKET_PACKAGE"); result.put("claimId", row.get("id")); result.put("claimNo", text(row.get("claimNo"))); result.put("title", "观影券1张"); result.put("quantity", 1); result.put("packageName", text(row.get("packageName"))); result.put("merchantName", text(row.get("merchantName"))); result.put("customerName", displayName(row.get("talentName"), number(row.get("gender")))); result.put("customerMobile", maskMobile(String.valueOf(row.get("talentMobile")))); result.put("cardLevel", cardLevel); result.put("status", used ? "completed" : "pending_verification"); result.put("statusText", used ? "已核销" : "待核销"); result.put("createdAt", row.get("createdAt")); result.put("usedAt", row.get("usedAt")); result.put("benefitText", ticketPackageBenefitText(row.get("merchantRightsConfig"), merchantId, cardLevel)); return result; } /** * 观影券详情的「当前权益」:与到店消费同一套口径——先取商家人才权益里该等级配置的描述, * 没配再回落到商户的到店消费权益文案,避免商户在观影券详情看到一句没有信息量的兜底话术。 */ private String ticketPackageBenefitText(Object rightsConfig, Long merchantId, String cardLevel) { if (cardLevel.isBlank()) return ""; String configured = merchantBenefitDetail(rightsConfig, cardLevel); if (!configured.isBlank()) return configured; return manualConsumptionService.benefitTextForMerchant(merchantId, cardLevel); } @Transactional public Map verify(String orderNo, MerchantPrincipal principal, VerifyRequest request, String idempotencyKey) { if (idempotencyKey == null || idempotencyKey.isBlank()) { throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键"); } Map order = order(orderNo, principal.merchantId(), principal.storeId()); // 先锁订单行,再复查幂等键和资金状态:并发核销(同键或不同键)都排队在这把锁上, // 后来者要么拿到首次结果,要么看到订单已核销。 OrderFundBoundaryService.LockedOrder locked = orderFundBoundary.lock(number(order.get("id"))); List> repeated = query(""" select order_id from verify_record where idempotency_key = ? and merchant_id = ? limit 1 """, idempotencyKey.trim(), principal.merchantId()); if (!repeated.isEmpty()) { if (!String.valueOf(order.get("id")).equals(String.valueOf(repeated.get(0).get("orderId")))) { throw new BusinessException(ErrorCode.CONFLICT, "幂等键已用于其他订单"); } return order(orderNo, principal.merchantId(), principal.storeId()); } locked.requireVerifiable(); // 扫码后核销会带上扫码尝试编号:核销成功时要把这次尝试标记为已完成,并校验扫码码未过期。 Attempt attempt = attempt(request.verificationId(), principal); // 核销只有一份实现:置订单已核销、写核销记录、确认延迟入账的资金。确认失败不会回滚核销 // (用户的服务已经核销掉了),资金保持待确认并由补偿任务重试。 String verifyNo = orderVerificationService.verifyByMerchant(principal.merchantId(), principal.storeId(), principal.operatorId(), locked.orderId(), verifyType(request.verifyType()), value(order.get("payAmount")), value(order.get("subsidyAmount")), request.remark(), attempt.id(), idempotencyKey.trim(), attempt.expiresAt()); if (verifyNo.isBlank()) { throw new BusinessException(ErrorCode.CONFLICT, "订单已核销"); } return order(orderNo, principal.merchantId(), principal.storeId()); } /** 核销方式不传时按商户现场核销处理。 */ private String verifyType(String requested) { return requested == null || requested.isBlank() ? "merchant" : requested.trim(); } /** * 手输 6 位数字核销码之后的确认核销。 * *

为什么不复用 {@code /api/merchant/orders/{orderNo}/verify}:手输那条路的扫码答复刻意不返回 * 「这个码属于哪笔订单」(6 位只有 100 万空间,带回去等于给枚举一张映射表),所以客户端拿不到单号。 * 这里由服务端从扫码尝试反查订单,再走同一套核销实现——幂等、行锁、资金确认都不重复写第二份。 */ @Transactional public Map confirmNumericVerification(MerchantPrincipal principal, String verificationId, String idempotencyKey) { if (idempotencyKey == null || idempotencyKey.isBlank()) { throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键"); } String attemptId = verificationId == null ? "" : verificationId.trim(); if (attemptId.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少核销凭证编号"); List> orders = query(""" select o.order_no from merchant_verification_attempt a join order_dynamic_verification_code c on c.id = a.order_verification_code_id join benefit_order o on o.id = c.order_id and o.deleted = 0 where a.verification_id = ? and a.credential_type = 'ORDER_CODE' and a.merchant_id = ? and a.store_id = ? and a.operator_id = ? limit 1 """, attemptId, principal.merchantId(), principal.storeId(), principal.operatorId()); if (orders.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "核销凭证不存在或无权操作"); return verify(String.valueOf(orders.get(0).get("orderNo")).trim(), principal, new VerifyRequest("ORDER_CODE", "", attemptId), idempotencyKey); } /** 扫码尝试:核销成功后要标记完成,并且只有扫码码未过期才能核销。 */ private record Attempt(Long id, java.time.LocalDateTime expiresAt) { } /** 把扫码尝试编号解析成主键与有效期;不属于当前商户/门店/操作员的尝试视为不存在。 */ private Attempt attempt(String verificationId, MerchantPrincipal principal) { if (verificationId == null || verificationId.isBlank()) return new Attempt(null, null); List> attempts = query(""" select id, expires_at from merchant_verification_attempt where verification_id = ? and merchant_id = ? and store_id = ? and operator_id = ? limit 1 """, verificationId.trim(), principal.merchantId(), principal.storeId(), principal.operatorId()); if (attempts.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "核销凭证不存在或无权操作"); return new Attempt(number(attempts.get(0).get("id")), time(attempts.get(0).get("expiresAt"))); } @Transactional public Map revokeVerification(MerchantPrincipal principal, MerchantRevokeVerificationRequest request, String idempotencyKey) { if (idempotencyKey == null || idempotencyKey.isBlank()) { throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键"); } String normalizedIdempotencyKey = idempotencyKey.trim(); Long orderId; try { orderId = Long.valueOf(request.orderId().trim()); } catch (NumberFormatException exception) { throw new BusinessException(ErrorCode.BAD_REQUEST, "订单编号无效"); } List> keyOwner = query(""" select order_id from verify_record where revoke_idempotency_key = ? limit 1 """, normalizedIdempotencyKey); // 幂等键归属必须按值比较:两个 Long 用 != 只在 -128..127 的缓存区间内碰巧相等, // 订单 ID 一旦超过这个区间,重复撤销就会被误判成「幂等键已用于其他订单」。 if (!keyOwner.isEmpty() && !Objects.equals(number(keyOwner.get(0).get("orderId")), orderId)) { throw new BusinessException(ErrorCode.CONFLICT, "幂等键已用于其他订单"); } List> repeated = query(""" select vr.revoked_at from verify_record vr join benefit_order o on o.id = vr.order_id where vr.revoke_idempotency_key = ? and o.id = ? and o.deleted = 0 and o.merchant_id = ? and (o.store_id is null or o.store_id = ?) limit 1 """, normalizedIdempotencyKey, orderId, principal.merchantId(), principal.storeId()); if (!repeated.isEmpty()) { return revokeResult(orderId, time(repeated.get(0).get("revokedAt"))); } List> orders = query(""" select o.id, o.order_no, o.order_type, o.order_status, o.verify_status, o.merchant_id, o.store_id, vr.id verify_record_id, vr.verify_time from benefit_order o left join verify_record vr on vr.order_id = o.id and vr.status = 1 where o.id = ? and o.deleted = 0 and o.merchant_id = ? and (o.store_id is null or o.store_id = ?) """, orderId, principal.merchantId(), principal.storeId()); if (orders.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "订单不存在"); Map order = orders.get(0); // 撤销核销与核销、退款共用同一把订单行锁;锁内读到的核销记录才是稳定的。 orderFundBoundary.lock(orderId); if (!"GROUP_BUY".equals(String.valueOf(order.get("orderType")))) { throw new BusinessException(ErrorCode.CONFLICT, "当前业务类型不支持撤销核销"); } if (number(order.get("verifyStatus")) == null || number(order.get("verifyStatus")) != 1 || number(order.get("verifyRecordId")) == null) { throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可撤销核销"); } LocalDateTime verifyTime = time(order.get("verifyTime")); if (verifyTime == null || Duration.between(verifyTime, LocalDateTime.now()).toHours() >= REVOCATION_HOURS) { throw new BusinessException(ErrorCode.CONFLICT, "已过可撤销时间"); } // 核销成功时钱已经确认给了商户(基本户),撤销核销必须先把钱退回通道延迟户。回退没成功就不许 // 翻单:否则订单显示「待核销」,钱却已经结算给商户,商户还能拿着这张单再核销一次。 String revertOutcome = fundConfirmation.revertForOrder(orderId); if (!PaymentFundConfirmationService.moneyBackInDelayAccount(revertOutcome)) { throw new BusinessException(ErrorCode.CONFLICT, PaymentFundConfirmationService.revertRefusalMessage(revertOutcome)); } String reason = request.reason() == null || request.reason().isBlank() ? "商户撤销核销" : request.reason().trim(); int updated = jdbcTemplate.update(""" update verify_record set status = 2, revoked_at = now(), revoked_by = ?, revoke_reason = ?, revoke_idempotency_key = ? where id = ? and status = 1 """, principal.operatorId(), reason, normalizedIdempotencyKey, number(order.get("verifyRecordId"))); if (updated == 0) throw new BusinessException(ErrorCode.CONFLICT, "核销已被处理,请刷新后重试"); jdbcTemplate.update("update benefit_order set verify_status = 0 where id = ? and verify_status = 1", orderId); return revokeResult(orderId, LocalDateTime.now()); } private Map revokeResult(Long orderId, LocalDateTime revokedAt) { Map result = new LinkedHashMap<>(); result.put("orderId", String.valueOf(orderId)); result.put("orderStatus", "pending_verification"); result.put("orderStatusText", "待核销"); result.put("revokedAt", (revokedAt == null ? LocalDateTime.now() : revokedAt).toString()); return result; } private String revokeReason(String orderType) { return "GROUP_BUY".equals(orderType) ? "已过可撤销时间" : "当前业务类型不支持撤销核销"; } private Map enrichOrder(Map order) { Map result = new LinkedHashMap<>(order); String orderType = String.valueOf(order.get("orderType")); 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; 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", statusText(status, refundStatus, category)); 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)); result.put("benefitName", currentBenefitText(order, category)); long orderAmount = number(order.get("orderAmount")) == null ? 0 : number(order.get("orderAmount")); long payAmount = number(order.get("payAmount")) == null ? 0 : number(order.get("payAmount")); long basePayAmount = number(order.get("basePayAmount")) == null ? orderAmount : number(order.get("basePayAmount")); long couponAmount = number(order.get("couponAmount")) == null ? 0 : number(order.get("couponAmount")); long configuredSubsidyAmount = number(order.get("subsidyAmount")) == null ? 0 : number(order.get("subsidyAmount")); long totalDiscount = Math.max(0, orderAmount - payAmount); long talentDiscountAmount = Math.max(0, orderAmount - basePayAmount); if ("GROUP_BUY".equals(orderType)) { couponAmount = Math.max(0, Math.min(couponAmount, totalDiscount)); // Older group-buy orders were created before all price snapshots were // consistently persisted. Reconcile the displayed breakdown with the // actual paid amount while preserving any valid coupon snapshot. if (talentDiscountAmount + couponAmount != totalDiscount) { talentDiscountAmount = Math.max(0, totalDiscount - couponAmount); } result.put("subsidyAmount", totalDiscount); } result.put("talentDiscountAmount", talentDiscountAmount); result.put("couponAmount", couponAmount); // 免费入住没有 benefit_order 金额,补贴来自核销记录中的后台配置,不能按原价减实付覆盖为 0。 result.put("subsidyAmount", "FREE_STAY".equals(orderType) ? configuredSubsidyAmount : totalDiscount); boolean canRevoke = "GROUP_BUY".equals(orderType) && verifyTime != null && Duration.between(verifyTime, LocalDateTime.now()).toHours() < REVOCATION_HOURS && verifyStatus != null && verifyStatus == 1; result.put("canRevoke", canRevoke); result.put("revokeDeadline", verifyTime == null ? null : verifyTime.plusHours(REVOCATION_HOURS).toString()); result.put("revokeReason", canRevoke ? "" : revokeReason(orderType)); return result; } private List> query(String sql, Object... args) { return DatabaseRows.normalize(jdbcTemplate.queryForList(sql, args)); } private OrderFilters filters(MerchantOrderQuery query) { MerchantOrderQuery value = query == null ? new MerchantOrderQuery(null, null, null, null) : query; StringBuilder conditions = new StringBuilder(); List arguments = new ArrayList<>(); String category = normalize(value.category()); if (!category.isEmpty()) { String orderType = switch (category) { case "group" -> "GROUP_BUY"; case "store" -> "STORE_CONSUME"; case "stay" -> "FREE_STAY"; default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "订单业务类型无效"); }; conditions.append(" and o.order_type = ?"); arguments.add(orderType); } 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 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, "订单状态无效"); } } LocalDate startDate = date(value.startDate(), "开始日期"); LocalDate endDate = date(value.endDate(), "结束日期"); if (startDate != null && endDate != null && startDate.isAfter(endDate)) { throw new BusinessException(ErrorCode.BAD_REQUEST, "开始日期不能晚于结束日期"); } if (startDate != null) { conditions.append(" and o.created_at >= ?"); arguments.add(Timestamp.valueOf(startDate.atStartOfDay())); } if (endDate != null) { conditions.append(" and o.created_at < ?"); arguments.add(Timestamp.valueOf(endDate.plusDays(1).atStartOfDay())); } return new OrderFilters(conditions.toString(), arguments); } private Map toListItem(Map order) { Map result = new LinkedHashMap<>(order); String category = category(String.valueOf(order.get("orderType"))); 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, refundStatus, category)); 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 result; } private String orderTitle(Map order, String category) { String snapshot = text(order.get("productNameSnapshot")); if (!snapshot.isBlank()) return snapshot; String benefit = text(order.get("benefitName")); return benefit.isBlank() ? categoryTitle(category) : benefit; } private String currentBenefitText(Map order, String category) { String cardLevel = text(order.get("cardLevel")); String merchantDetail = merchantBenefitDetail(order.get("merchantRightsConfig"), cardLevel); if (!merchantDetail.isBlank()) return merchantDetail; if ("store".equals(category)) { Long merchantId = number(order.get("merchantId")); return merchantId == null ? "以当前商户到店消费规则为准" : manualConsumptionService.benefitTextForMerchant(merchantId, cardLevel); } if ("group".equals(category)) { ProductPriceCalculator.ProductRight right = ProductPriceCalculator.productRight(order.get("rightsConfig"), cardLevel); if (right != null) return productBenefitText(right); } String configured = text(order.get("benefitName")); return configured.isBlank() ? "以当前订单权益规则为准" : configured; } private String merchantBenefitDetail(Object rawConfig, String cardLevel) { if (rawConfig == null || cardLevel.isBlank()) return ""; try { List> configs = rawConfig instanceof List ? ((List) rawConfig).stream().filter(Map.class::isInstance) .map(item -> (Map) item).toList() : OBJECT_MAPPER.readValue(String.valueOf(rawConfig), RIGHTS_TYPE); for (Map config : configs) { if (!booleanValue(config.get("enabled")) || !sameCardLevel(config.get("level"), cardLevel)) continue; String detail = text(config.get("detail")); if (!detail.isBlank()) return detail; } } catch (Exception ignored) { // Invalid legacy configuration should fall back to the structured rule text. } return ""; } private boolean sameCardLevel(Object configured, String actual) { return normalizeCardLevel(configured).equals(normalizeCardLevel(actual)); } private String normalizeCardLevel(Object value) { String raw = text(value).toUpperCase(); if (raw.contains("卓越") || "EXCELLENT".equals(raw)) return "EXCELLENT"; if (raw.contains("杰出") || "OUTSTANDING".equals(raw)) return "OUTSTANDING"; if (raw.contains("菁英") || "ELITE".equals(raw)) return "ELITE"; return raw; } private boolean booleanValue(Object value) { return value instanceof Boolean bool ? bool : "1".equals(text(value)) || "true".equalsIgnoreCase(text(value)); } private String productBenefitText(ProductPriceCalculator.ProductRight right) { String discountText = discountText(right.discount()); if (!right.free()) return discountText.isBlank() ? "以商品配置为准" : "按市价" + discountText; String freeText = right.limitCount() > 0 ? "免费" + right.limitCount() + "次/" + cycleText(right.resetCycle()) : "免费"; return discountText.isBlank() ? freeText : freeText + ",超出次数按市价" + discountText; } private String discountText(BigDecimal discount) { if (discount == null || discount.signum() <= 0) return ""; return discount.stripTrailingZeros().toPlainString() + "折"; } private String cycleText(String cycle) { String value = normalize(cycle).toUpperCase(); return switch (value) { case "DAY", "DAILY", "每天" -> "天"; case "WEEK", "WEEKLY", "每周" -> "周"; case "MONTH", "MONTHLY", "每月" -> "月"; default -> "年"; }; } private String category(String orderType) { if ("GROUP_BUY".equals(orderType)) return "group"; if ("STORE_CONSUME".equals(orderType)) return "store"; if ("FREE_STAY".equals(orderType)) return "stay"; return "group"; } 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, Long refundStatus, String category) { if ("pending_payment".equals(status)) return "待支付"; // 团购是到店核销的业务:已完成在商户眼里就是「已核销」,原型与列表口径都这么写。 if ("completed".equals(status)) return "group".equals(category) ? "已核销" : "已完成"; if ("cancelled".equals(status)) return "已取消"; if ("refunded".equals(status)) return refundStatus != null && refundStatus == 1 ? "退款中" : "已退款"; return "待核销"; } private String categoryTitle(String category) { if ("store".equals(category)) return "到店消费订单"; if ("stay".equals(category)) return "免费入住"; return "团购订单"; } private String maskName(String value) { if (value == null || value.isBlank() || "null".equals(value)) return ""; return value.length() == 1 ? value + "*" : value.substring(0, 1) + "*".repeat(value.length() - 1); } private String displayName(Object rawName, Long gender) { String name = text(rawName); if (name.isBlank()) return ""; if (gender != null && gender == 1) return name.substring(0, 1) + "先生"; if (gender != null && gender == 2) return name.substring(0, 1) + "女士"; return maskName(name); } private String text(Object value) { return value == null || "null".equals(String.valueOf(value)) ? "" : String.valueOf(value).trim(); } private String maskMobile(String value) { if (value == null || value.length() < 7 || "null".equals(value)) return ""; return value.substring(0, 3) + "****" + value.substring(value.length() - 4); } private String normalize(String value) { return value == null ? "" : value.trim(); } private LocalDate date(String value, String field) { if (value == null || value.isBlank()) return null; try { return LocalDate.parse(value); } catch (Exception exception) { throw new BusinessException(ErrorCode.BAD_REQUEST, field + "格式无效"); } } private Long number(Object value) { return value instanceof Number number ? number.longValue() : null; } /** 与 {@link #number} 同义,但缺值时按 0 处理:核销金额为空就是 0 分。 */ private long value(Object value) { return value instanceof Number number ? number.longValue() : 0L; } private LocalDateTime time(Object value) { if (value instanceof LocalDateTime dateTime) return dateTime; if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime(); return null; } private record OrderFilters(String conditions, List arguments) { } }