package com.tcm.training.admin.points.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.tcm.training.admin.member.entity.AppUser; import com.tcm.training.admin.member.mapper.UserMapper; import com.tcm.training.admin.live.entity.Clinic; import com.tcm.training.admin.live.mapper.ClinicMapper; import com.tcm.training.admin.points.entity.PointsConfig; import com.tcm.training.admin.points.entity.PointsExchangeOrder; import com.tcm.training.admin.points.entity.PointsExchangeOrderItem; import com.tcm.training.admin.points.entity.UserPointsAccount; import com.tcm.training.admin.points.mapper.PointsConfigMapper; import com.tcm.training.admin.points.mapper.PointsExchangeOrderItemMapper; import com.tcm.training.admin.points.mapper.PointsExchangeOrderMapper; import com.tcm.training.admin.points.mapper.UserPointsAccountMapper; import com.tcm.training.admin.points.row.PointsSettlementCandidateRow; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @Service public class PointsSettlementService { private static final DateTimeFormatter ORDER_NO_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); private static final String SETTLEMENT_CLEAR_SOURCE_PREFIX = "config-clear-"; private static final String SETTLEMENT_CLEAR_REFERENCE_PREFIX = "POINTS_CONFIG-"; private static final Logger log = LoggerFactory.getLogger(PointsSettlementService.class); private final PointsConfigMapper pointsConfigMapper; private final UserPointsAccountMapper userPointsAccountMapper; private final PointsExchangeOrderMapper pointsExchangeOrderMapper; private final PointsExchangeOrderItemMapper pointsExchangeOrderItemMapper; private final UserMapper userMapper; private final ClinicMapper clinicMapper; private final PointsLedgerAppService pointsLedgerAppService; public PointsSettlementService(PointsConfigMapper pointsConfigMapper, UserPointsAccountMapper userPointsAccountMapper, PointsExchangeOrderMapper pointsExchangeOrderMapper, PointsExchangeOrderItemMapper pointsExchangeOrderItemMapper, UserMapper userMapper, ClinicMapper clinicMapper, PointsLedgerAppService pointsLedgerAppService) { this.pointsConfigMapper = pointsConfigMapper; this.userPointsAccountMapper = userPointsAccountMapper; this.pointsExchangeOrderMapper = pointsExchangeOrderMapper; this.pointsExchangeOrderItemMapper = pointsExchangeOrderItemMapper; this.userMapper = userMapper; this.clinicMapper = clinicMapper; this.pointsLedgerAppService = pointsLedgerAppService; } @Scheduled(fixedDelayString = "${app.points.settlement-interval-ms:60000}") public void settleExpiredConfigs() { LocalDateTime now = LocalDateTime.now(); List configs = pointsConfigMapper.selectList(new LambdaQueryWrapper() .eq(PointsConfig::getDeleted, 0) .eq(PointsConfig::getSettlementStatus, 0) .le(PointsConfig::getActivityEndAt, now) .orderByAsc(PointsConfig::getId)); if (!configs.isEmpty()) { log.info("points settlement scan found {} expired pending configs", configs.size()); } for (PointsConfig config : configs) { settleConfig(config.getId()); } } @Transactional public void settleConfig(Long configId) { PointsConfig config = pointsConfigMapper.selectById(configId); if (config == null || config.getDeleted() != null && config.getDeleted() == 1) { return; } if (config.getSettlementStatus() != null && config.getSettlementStatus() == 1) { return; } if (config.getActivityEndAt() == null || config.getActivityEndAt().isAfter(LocalDateTime.now())) { return; } log.info("points settlement start, configId={}, activityEndAt={}, requiredPoints={}", config.getId(), config.getActivityEndAt(), config.getRequiredPoints()); List candidates = userPointsAccountMapper.selectSettlementCandidates(); List createdItems = new ArrayList(); LocalDateTime now = LocalDateTime.now(); int qualifiedCount = 0; int clearedOnlyCount = 0; for (PointsSettlementCandidateRow candidate : candidates) { int balance = candidate.getPointsBalance() == null ? 0 : candidate.getPointsBalance(); if (balance <= 0) { continue; } if (balance >= config.getRequiredPoints()) { PointsExchangeOrderItem item = buildOrderItem(config, candidate, now); pointsExchangeOrderItemMapper.insert(item); createdItems.add(item); qualifiedCount++; } else { pointsLedgerAppService.createSettlementClearLedger( buildSettlementClearSourceId(config.getId(), candidate.getAppUserId()), candidate.getAppUserId(), candidate.getUserNickname(), candidate.getUserPhone(), candidate.getClinicId(), candidate.getClinicName(), candidate.getProvinceCode(), candidate.getProvinceName(), candidate.getCityCode(), candidate.getCityName(), candidate.getDistrictCode(), candidate.getDistrictName(), config.getGiftName(), config.getId(), buildSettlementReferenceNo(config.getId()), balance, now ); clearedOnlyCount++; } UserPointsAccount account = userPointsAccountMapper.selectById(candidate.getAccountId()); if (account != null) { account.setPointsBalance(0); account.setLastSettledConfigId(config.getId()); account.setLastClearedAt(now); userPointsAccountMapper.updateById(account); } } buildSummaryOrders(config, createdItems, now); config.setSettlementStatus(1); config.setSettledAt(now); pointsConfigMapper.updateById(config); log.info("points settlement completed, configId={}, qualifiedCount={}, clearedOnlyCount={}, createdItemCount={}, settledAt={}", config.getId(), qualifiedCount, clearedOnlyCount, createdItems.size(), now); } private PointsExchangeOrderItem buildOrderItem(PointsConfig config, PointsSettlementCandidateRow candidate, LocalDateTime now) { PointsExchangeOrderItem item = new PointsExchangeOrderItem(); item.setOrderNo(buildOrderNo("PXD", candidate.getAppUserId(), now)); item.setAppUserId(candidate.getAppUserId()); item.setUserNickname(candidate.getUserNickname()); item.setUserPhone(candidate.getUserPhone()); item.setClinicId(candidate.getClinicId()); item.setClinicName(candidate.getClinicName()); item.setProvinceCode(candidate.getProvinceCode()); item.setProvinceName(candidate.getProvinceName()); item.setCityCode(candidate.getCityCode()); item.setCityName(candidate.getCityName()); item.setDistrictCode(candidate.getDistrictCode()); item.setDistrictName(candidate.getDistrictName()); item.setSettledPoints(candidate.getPointsBalance()); item.setStatus(0); item.setCreatedAt(now); return item; } private void buildSummaryOrders(PointsConfig config, List items, LocalDateTime now) { if (items.isEmpty()) { return; } Map> grouped = new LinkedHashMap>(); for (PointsExchangeOrderItem item : items) { String key = buildGroupKey(item); List group = grouped.get(key); if (group == null) { group = new ArrayList(); grouped.put(key, group); } group.add(item); } for (Map.Entry> entry : grouped.entrySet()) { List group = entry.getValue(); PointsExchangeOrderItem first = group.get(0); PointsExchangeOrder order = new PointsExchangeOrder(); order.setOrderNo(buildOrderNo("PXS", first.getClinicId() == null ? first.getAppUserId() : first.getClinicId(), now)); order.setConfigId(config.getId()); order.setGiftCover(config.getGiftCover()); order.setGiftName(config.getGiftName()); order.setRequiredPoints(config.getRequiredPoints()); order.setActivityStartAt(config.getActivityStartAt()); order.setActivityEndAt(config.getActivityEndAt()); order.setClinicId(first.getClinicId()); order.setClinicAppUserId(resolveClinicAppUserId(first.getClinicId())); order.setClinicName(first.getClinicName()); order.setReceiverName(defaultReceiverName(first)); order.setReceiverPhone(defaultReceiverPhone(first)); order.setReceiverAddress(defaultReceiverAddress(first)); order.setItemCount(group.size()); order.setTotalPoints(sumPoints(group)); order.setStatus(0); pointsExchangeOrderMapper.insert(order); for (PointsExchangeOrderItem item : group) { item.setOrderId(order.getId()); pointsExchangeOrderItemMapper.updateById(item); pointsLedgerAppService.createExchangeExpenseLedger( item.getId(), item.getAppUserId(), item.getUserNickname(), item.getUserPhone(), item.getClinicId(), item.getClinicName(), item.getProvinceCode(), item.getProvinceName(), item.getCityCode(), item.getCityName(), item.getDistrictCode(), item.getDistrictName(), config.getGiftName(), item.getId(), item.getOrderNo(), item.getSettledPoints(), item.getCreatedAt() == null ? now : item.getCreatedAt() ); } } } private String buildGroupKey(PointsExchangeOrderItem item) { if (item.getClinicId() != null) { return "clinic:" + item.getClinicId(); } return "user:" + item.getAppUserId(); } private int sumPoints(List items) { int total = 0; for (PointsExchangeOrderItem item : items) { total += item.getSettledPoints() == null ? 0 : item.getSettledPoints(); } return total; } private Long resolveClinicAppUserId(Long clinicId) { if (clinicId == null) { return null; } AppUser clinicUser = userMapper.selectOne(new LambdaQueryWrapper() .eq(AppUser::getClinicId, clinicId) .eq(AppUser::getType, 1) .eq(AppUser::getDeleted, 0) .last("limit 1")); return clinicUser == null ? null : clinicUser.getId(); } private String buildOrderNo(String prefix, Long seed, LocalDateTime now) { long suffix = seed == null ? System.nanoTime() % 100000 : seed % 100000; return prefix + ORDER_NO_FORMATTER.format(now) + String.format("%05d", suffix); } private String defaultReceiverName(PointsSettlementCandidateRow candidate) { if (candidate.getClinicId() != null) { Clinic clinic = clinicMapper.selectById(candidate.getClinicId()); if (clinic != null && clinic.getContactName() != null && !clinic.getContactName().trim().isEmpty()) { return clinic.getContactName(); } } return candidate.getUserNickname(); } private String defaultReceiverPhone(PointsSettlementCandidateRow candidate) { if (candidate.getClinicContactPhone() != null && !candidate.getClinicContactPhone().trim().isEmpty()) { return candidate.getClinicContactPhone(); } return candidate.getUserPhone(); } private String defaultReceiverAddress(PointsSettlementCandidateRow candidate) { if (candidate.getClinicAddress() != null && !candidate.getClinicAddress().trim().isEmpty()) { return candidate.getClinicAddress(); } return "-"; } private String defaultReceiverName(PointsExchangeOrderItem item) { if (item.getClinicId() != null) { Clinic clinic = clinicMapper.selectById(item.getClinicId()); if (clinic != null && clinic.getContactName() != null && !clinic.getContactName().trim().isEmpty()) { return clinic.getContactName(); } } return item.getUserNickname(); } private String defaultReceiverPhone(PointsExchangeOrderItem item) { if (item.getClinicId() != null) { Clinic clinic = clinicMapper.selectById(item.getClinicId()); if (clinic != null && clinic.getContactPhone() != null && !clinic.getContactPhone().trim().isEmpty()) { return clinic.getContactPhone(); } } return item.getUserPhone(); } private String defaultReceiverAddress(PointsExchangeOrderItem item) { if (item.getClinicId() != null) { Clinic clinic = clinicMapper.selectById(item.getClinicId()); if (clinic != null && clinic.getAddress() != null && !clinic.getAddress().trim().isEmpty()) { return clinic.getAddress(); } } return "-"; } /** * 构建活动结束清零流水的唯一来源标识,确保重复调度不会重复入账。 */ private Long buildSettlementClearSourceId(Long configId, Long appUserId) { String compositeId = SETTLEMENT_CLEAR_SOURCE_PREFIX + (configId == null ? "0" : configId) + "-" + (appUserId == null ? "0" : appUserId); return Long.valueOf(Math.abs((long) compositeId.hashCode())); } /** * 构建活动结算流水引用编号,便于后台追溯到积分活动。 */ private String buildSettlementReferenceNo(Long configId) { return SETTLEMENT_CLEAR_REFERENCE_PREFIX + (configId == null ? 0L : configId.longValue()); } }