package com.talentcard.server.business.portal;
|
|
import java.math.BigDecimal;
|
import java.time.LocalDate;
|
import java.time.LocalDateTime;
|
import java.time.temporal.TemporalAdjusters;
|
import java.util.LinkedHashMap;
|
import java.util.List;
|
import java.util.Locale;
|
import java.util.Map;
|
|
import com.talentcard.server.business.BusinessIdGenerator;
|
import com.talentcard.server.business.DatabaseRows;
|
import com.talentcard.server.business.MoneyUtils;
|
import com.talentcard.server.common.BusinessException;
|
import com.talentcard.server.common.ErrorCode;
|
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
|
@Service
|
public class AppOrderPortalService {
|
private final JdbcTemplate jdbc;
|
private final PendingOrderPortalService pendingOrderPortalService;
|
|
public AppOrderPortalService(JdbcTemplate jdbc, PendingOrderPortalService pendingOrderPortalService) {
|
this.jdbc = jdbc;
|
this.pendingOrderPortalService = pendingOrderPortalService;
|
}
|
|
@Transactional
|
public Map<String, Object> create(Long talentId, AppOrderCreateRequest request, String rawIdempotencyKey) {
|
// A coupon is reserved only by an unpaid order. Clear stale reservations
|
// before checking/reusing an order so a user can buy again immediately
|
// after the previous 15-minute payment countdown has elapsed.
|
pendingOrderPortalService.expireDueOrders(talentId);
|
Map<String, Object> talentCard = activePersonalCard(talentId);
|
Map<String, Object> product = purchasableProduct(request.merchantId(), request.productId());
|
int usedFreeCount = usedFreeCount(talentId, request.productId(), product.get("rightsConfig"), talentCard.get("cardLevel"));
|
ProductPriceCalculator.ProductPrice personalizedPrice = ProductPriceCalculator.calculate(product.get("originalPrice"), product.get("salePrice"), product.get("rightsConfig"), talentCard.get("cardLevel"), usedFreeCount);
|
Map<String, Object> existing = findReusableOrder(talentId, request.merchantId(), request.productId(), request.couponId());
|
if (existing != null) return existing;
|
Map<String, Object> coupon = request.couponId() == null ? null : usableCoupon(talentId, request.merchantId(), request.productId(), personalizedPrice.salePrice(), request.couponId(), String.valueOf(talentCard.get("cardLevel")));
|
|
long originalAmount = MoneyUtils.yuanToCent(personalizedPrice.originalPrice());
|
long basePayAmount = MoneyUtils.yuanToCent(personalizedPrice.salePrice());
|
// Keep the original-price snapshot meaningful even when an old product
|
// record has no original_price configured.
|
originalAmount = Math.max(originalAmount, basePayAmount);
|
long couponAmount = coupon == null ? 0 : Math.min(MoneyUtils.yuanToCent(decimal(coupon.get("couponAmount"))), basePayAmount);
|
long payAmount = Math.max(0, basePayAmount - couponAmount);
|
// 订单补贴金额需与待支付页保持一致:人才折扣与优惠券抵扣的合计。
|
long subsidyAmount = Math.max(0, originalAmount - payAmount);
|
String orderNo = BusinessIdGenerator.next("P");
|
LocalDateTime expiresAt = payAmount == 0 ? null : LocalDateTime.now().plusMinutes(15);
|
|
try {
|
jdbc.update("""
|
insert into benefit_order(order_no, talent_id, card_id, card_level_snapshot, merchant_id, order_type,
|
order_amount, subsidy_amount, base_pay_amount, pay_amount,
|
coupon_id, coupon_amount, expires_at, price_version,
|
merchant_name_snapshot, merchant_address_snapshot, merchant_mobile_snapshot,
|
merchant_longitude_snapshot, merchant_latitude_snapshot,
|
product_id, product_name_snapshot, product_cover_url_snapshot,
|
order_status, verify_status)
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
""",
|
orderNo,
|
talentId,
|
talentCard.get("id"),
|
talentCard.get("cardLevel"),
|
request.merchantId(),
|
normalizeOrderType(product.get("productType")),
|
originalAmount,
|
subsidyAmount,
|
basePayAmount,
|
payAmount,
|
request.couponId(),
|
couponAmount,
|
expiresAt,
|
1,
|
product.get("merchantName"),
|
product.get("address"),
|
product.get("contactMobile"),
|
product.get("longitude"),
|
product.get("latitude"),
|
request.productId(),
|
product.get("productName"),
|
product.get("coverUrl"),
|
payAmount == 0 ? 1 : 0,
|
0);
|
} catch (DuplicateKeyException exception) {
|
Map<String, Object> reused = findReusableOrder(talentId, request.merchantId(), request.productId(), request.couponId());
|
if (reused != null) return reused;
|
throw exception;
|
}
|
return createdOrder(orderNo, talentId);
|
}
|
|
private Map<String, Object> activePersonalCard(Long talentId) {
|
List<Map<String, Object>> rows = query("""
|
select id, card_no, card_level
|
from talent_card
|
where deleted = 0 and status = 1 and talent_id = ? and card_type = 'PERSONAL'
|
and (valid_start is null or valid_start <= current_date)
|
and (valid_end is null or valid_end >= current_date)
|
and (expire_time is null or expire_time > current_timestamp)
|
order by id desc
|
limit 1
|
""", talentId);
|
if (rows.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "当前暂无有效人才卡,不能购买权益商品");
|
return rows.get(0);
|
}
|
|
private Map<String, Object> purchasableProduct(Long merchantId, Long productId) {
|
List<Map<String, Object>> rows = query("""
|
select p.id, p.product_type, p.product_name, p.cover_url, p.original_price, p.sale_price, p.rights_config,
|
p.subsidy_amount, p.stock, p.status, p.audit_status,
|
m.merchant_name, m.address, m.contact_mobile, m.longitude, m.latitude, m.status as merchant_status
|
from merchant_product p
|
join merchant m on m.id = p.merchant_id and m.deleted = 0
|
where p.deleted = 0 and p.id = ? and p.merchant_id = ?
|
limit 1
|
""", productId, merchantId);
|
if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商品不存在");
|
Map<String, Object> product = rows.get(0);
|
boolean available = intValue(product.get("merchantStatus")) == 1
|
&& intValue(product.get("status")) == 1
|
&& intValue(product.get("auditStatus")) == 2
|
&& intValue(product.get("stock")) != 0;
|
if (!available) throw new BusinessException(ErrorCode.CONFLICT, "商品当前不可购买");
|
return product;
|
}
|
|
private int usedFreeCount(Long talentId, Long productId, Object rightsConfig, Object cardLevel) {
|
ProductPriceCalculator.ProductRight right = ProductPriceCalculator.productRight(rightsConfig, cardLevel);
|
if (right == null || !right.free()) return 0;
|
Long count = jdbc.queryForObject("""
|
select count(*)
|
from benefit_order
|
where deleted = 0 and talent_id = ? and product_id = ? and pay_amount = 0 and order_status = 1
|
and created_at >= ?
|
""", Long.class, talentId, productId, freeWindowStart(right.resetCycle()));
|
return count == null ? 0 : count.intValue();
|
}
|
|
private LocalDateTime freeWindowStart(String resetCycle) {
|
String cycle = resetCycle == null ? "" : resetCycle.trim().toUpperCase(Locale.ROOT);
|
LocalDate today = LocalDate.now();
|
if (cycle.isBlank() || "NONE".equals(cycle)) return LocalDate.of(1970, 1, 1).atStartOfDay();
|
if (cycle.contains("DAY") || cycle.contains("天") || cycle.contains("日")) return today.atStartOfDay();
|
if (cycle.contains("WEEK") || cycle.contains("周") || cycle.contains("星期")) return today.minusDays(today.getDayOfWeek().getValue() - 1L).atStartOfDay();
|
if (cycle.contains("MONTH") || cycle.contains("月")) return today.withDayOfMonth(1).atStartOfDay();
|
return today.with(TemporalAdjusters.firstDayOfYear()).atStartOfDay();
|
}
|
|
private Map<String, Object> usableCoupon(Long talentId, Long merchantId, Long productId, Object salePrice, Long couponId, String cardLevel) {
|
List<Map<String, Object>> rows = query("""
|
select c.id, c.coupon_amount
|
from merchant_coupon c
|
where c.id = ? and c.deleted = 0 and c.status = 1 and c.merchant_id = ?
|
and (c.valid_start is null or c.valid_start <= current_date)
|
and (c.valid_end is null or c.valid_end >= current_date)
|
and (c.condition_type = 'NONE' or c.condition_amount is null or c.condition_amount <= ?)
|
and (coalesce(c.talent_levels, '') = '' or find_in_set(?, replace(c.talent_levels, '、', ',')) > 0)
|
and (c.product_scope = 'ALL' or exists (
|
select 1 from merchant_coupon_product cp
|
where cp.coupon_id = c.id and cp.product_id = ?
|
))
|
and not exists (
|
select 1 from benefit_order o
|
where o.coupon_id = c.id and o.talent_id = ? and o.deleted = 0 and o.order_status = 0
|
and (o.expires_at is null or o.expires_at > current_timestamp)
|
)
|
limit 1
|
""", couponId, merchantId, decimal(salePrice), cardLevel, productId, talentId);
|
if (rows.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "该优惠券当前不可用,请重新选择");
|
return rows.get(0);
|
}
|
|
private Map<String, Object> findReusableOrder(Long talentId, Long merchantId, Long productId, Long couponId) {
|
StringBuilder sql = new StringBuilder("""
|
select order_no
|
from benefit_order
|
where deleted = 0 and talent_id = ? and merchant_id = ? and product_id = ? and order_status = 0
|
and (expires_at is null or expires_at > current_timestamp)
|
""");
|
List<Object> args = new java.util.ArrayList<>();
|
args.add(talentId);
|
args.add(merchantId);
|
args.add(productId);
|
if (couponId == null) {
|
sql.append(" and coupon_id is null");
|
} else {
|
sql.append(" and coupon_id = ?");
|
args.add(couponId);
|
}
|
sql.append(" order by id desc limit 1");
|
List<Map<String, Object>> rows = query(sql.toString(), args.toArray());
|
if (rows.isEmpty()) return null;
|
return createdOrder(String.valueOf(rows.get(0).get("orderNo")), talentId);
|
}
|
|
private Map<String, Object> createdOrder(String orderNo, Long talentId) {
|
List<Map<String, Object>> rows = query("""
|
select order_no, talent_id, merchant_id, product_id, coupon_id, price_version, expires_at, order_status
|
from benefit_order
|
where deleted = 0 and order_no = ? and talent_id = ?
|
limit 1
|
""", orderNo, talentId);
|
if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "订单不存在");
|
Map<String, Object> order = rows.get(0);
|
Map<String, Object> result = new LinkedHashMap<>();
|
result.put("orderNo", order.get("orderNo"));
|
result.put("orderId", order.get("orderNo"));
|
result.put("talentId", order.get("talentId"));
|
result.put("merchantId", order.get("merchantId"));
|
result.put("productId", order.get("productId"));
|
result.put("couponId", order.get("couponId"));
|
result.put("priceVersion", order.get("priceVersion"));
|
result.put("expiresAt", order.get("expiresAt"));
|
result.put("paid", intValue(order.get("orderStatus")) == 1);
|
return result;
|
}
|
|
private String normalizeIdempotencyKey(String value) {
|
if (value == null || value.isBlank()) return null;
|
String trimmed = value.trim();
|
return trimmed.length() > 128 ? trimmed.substring(0, 128) : trimmed;
|
}
|
|
private String normalizeOrderType(Object value) {
|
String type = value == null ? "" : String.valueOf(value).trim().toLowerCase();
|
return switch (type) {
|
case "food", "group", "group_buy", "group-buy", "groupbuy" -> "GROUP_BUY";
|
case "store", "store_consume", "store-consume", "storeconsume" -> "STORE_CONSUME";
|
case "stay", "free_stay", "free-stay", "hotel" -> "FREE_STAY";
|
default -> String.valueOf(value).trim().isEmpty() ? "GROUP_BUY" : String.valueOf(value).trim().toUpperCase();
|
};
|
}
|
|
private List<Map<String, Object>> query(String sql, Object... args) {
|
return DatabaseRows.normalize(jdbc.queryForList(sql, args));
|
}
|
|
private BigDecimal decimal(Object value) {
|
if (value instanceof BigDecimal decimal) return decimal;
|
if (value instanceof Number number) return BigDecimal.valueOf(number.doubleValue());
|
if (value == null) return BigDecimal.ZERO;
|
try {
|
return new BigDecimal(String.valueOf(value));
|
} catch (Exception exception) {
|
return BigDecimal.ZERO;
|
}
|
}
|
|
private int intValue(Object value) {
|
return value instanceof Number number ? number.intValue() : 0;
|
}
|
}
|