talent-card-server/src/main/java/com/talentcard/server/service/TicketPackageAdminService.java
@@ -2,14 +2,17 @@
import java.io.IOException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
@@ -19,13 +22,21 @@
import com.talentcard.server.common.ErrorCode;
import com.talentcard.server.entity.BenefitMerchant;
import com.talentcard.server.entity.TicketPackage;
import com.talentcard.server.entity.TicketPackageClaim;
import com.talentcard.server.entity.TicketPackageCode;
import com.talentcard.server.entity.TicketPackageLevelRule;
import com.talentcard.server.entity.TicketPackageMerchant;
import com.talentcard.server.mapper.BenefitMerchantMapper;
import com.talentcard.server.mapper.TicketPackageClaimMapper;
import com.talentcard.server.mapper.TicketPackageCodeMapper;
import com.talentcard.server.mapper.TicketPackageLevelRuleMapper;
import com.talentcard.server.mapper.TicketPackageMapper;
import com.talentcard.server.mapper.TicketPackageMerchantMapper;
import com.talentcard.server.request.TicketPackageCodeImportRequest;
import com.talentcard.server.request.TicketPackageCodeGenerateRequest;
import com.talentcard.server.request.TicketPackageCodeLockRequest;
import com.talentcard.server.request.TicketPackageCodeStatusRequest;
import com.talentcard.server.request.TicketPackageSupplementRequest;
import com.talentcard.server.request.TicketPackageSaveRequest;
import org.apache.poi.EncryptedDocumentException;
import org.apache.poi.ss.usermodel.DataFormatter;
@@ -34,6 +45,7 @@
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
@@ -46,26 +58,63 @@
    private final TicketPackageCodeMapper codeMapper;
    private final TicketPackageClaimMapper claimMapper;
    private final BenefitMerchantMapper merchantMapper;
    private final TicketPackageMerchantMapper packageMerchantMapper;
    private final TicketPackageLevelRuleMapper levelRuleMapper;
    private final JdbcTemplate jdbcTemplate;
    public TicketPackageAdminService(
            TicketPackageMapper packageMapper,
            TicketPackageCodeMapper codeMapper,
            TicketPackageClaimMapper claimMapper,
            BenefitMerchantMapper merchantMapper
            BenefitMerchantMapper merchantMapper,
            TicketPackageMerchantMapper packageMerchantMapper,
            TicketPackageLevelRuleMapper levelRuleMapper,
            JdbcTemplate jdbcTemplate
    ) {
        this.packageMapper = packageMapper;
        this.codeMapper = codeMapper;
        this.claimMapper = claimMapper;
        this.merchantMapper = merchantMapper;
        this.packageMerchantMapper = packageMerchantMapper;
        this.levelRuleMapper = levelRuleMapper;
        this.jdbcTemplate = jdbcTemplate;
    }
    /**
     * 票包表单的「适用商家」下拉选项:只回启用的观影服务商家(id + 名称)。
     *
     * <p>单独开这个只读接口,是为了让「电影票管理」这个权限自己够用——之前页面复用
     * {@code /api/admin/business/merchants},票包运营账号没有「商家查询」权限就整页报错。
     */
    @Transactional(readOnly = true)
    public List<Map<String, Object>> merchantOptions(String keyword) {
        StringBuilder sql = new StringBuilder("""
                select id, merchant_name, merchant_type
                from merchant
                where deleted = 0 and status = 1 and merchant_type_code = 'cinema'
                """);
        List<Object> arguments = new ArrayList<>();
        if (keyword != null && !keyword.isBlank()) {
            sql.append(" and merchant_name like ?");
            arguments.add("%" + keyword.trim() + "%");
        }
        sql.append(" order by sort_no desc, id asc");
        return jdbcTemplate.queryForList(sql.toString(), arguments.toArray()).stream().map(row -> {
            Map<String, Object> option = new LinkedHashMap<>();
            option.put("id", row.get("id"));
            option.put("merchantName", row.get("merchant_name"));
            option.put("merchantType", row.get("merchant_type"));
            return option;
        }).toList();
    }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> packages(Long merchantId, Integer status, String keyword, int page, int size) {
    public PageResult<Map<String, Object>> packages(Long merchantId, Integer status, String keyword, String merchantKeyword, int page, int size) {
        int safePage = Math.max(page, 1);
        int safeSize = Math.min(Math.max(size, 1), 100);
        Integer normalizedStatus = normalizeStatusFilter(status);
        Long total = packageMapper.countAdminPage(merchantId, normalizedStatus, clean(keyword));
        List<Map<String, Object>> records = packageMapper.selectAdminPage(merchantId, normalizedStatus, clean(keyword), safeSize, (safePage - 1) * safeSize)
        Long total = packageMapper.countAdminPage(merchantId, normalizedStatus, clean(keyword), clean(merchantKeyword));
        List<Map<String, Object>> records = packageMapper.selectAdminPage(merchantId, normalizedStatus, clean(keyword), clean(merchantKeyword), safeSize, (safePage - 1) * safeSize)
                .stream()
                .map(this::packageView)
                .toList();
@@ -79,19 +128,21 @@
    @Transactional
    public Map<String, Object> createPackage(TicketPackageSaveRequest request) {
        validateMerchant(request.merchantId());
        List<Long> merchantIds = validateMerchants(request);
        TicketPackage entity = new TicketPackage();
        entity.setPackageNo(BusinessIdGenerator.next("TP"));
        apply(entity, request);
        entity.setDeleted(0);
        packageMapper.insert(entity);
        savePackageMerchants(entity.getId(), merchantIds);
        saveLevelRules(entity.getId(), request);
        return packageDetail(entity.getId());
    }
    @Transactional
    public Map<String, Object> updatePackage(Long id, TicketPackageSaveRequest request) {
        Long packageId = requireId(id, "票包ID不能为空");
        validateMerchant(request.merchantId());
        List<Long> merchantIds = validateMerchants(request);
        TicketPackage current = packageMapper.selectByIdForUpdate(packageId);
        if (current == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "票包不存在");
@@ -101,6 +152,8 @@
        if (updated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "票包配置已变化,请刷新后重试");
        }
        savePackageMerchants(packageId, merchantIds);
        saveLevelRules(packageId, request);
        return packageDetail(packageId);
    }
@@ -162,6 +215,57 @@
    }
    @Transactional
    public Map<String, Object> generateCodes(Long packageId, TicketPackageCodeGenerateRequest request) {
        TicketPackage ticketPackage = requirePackage(packageId);
        int target = Math.max(request.codeCount() == null ? 0 : request.codeCount(), 0);
        if (target < 1) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "生成数量不能为空");
        }
        String batchNo = clean(request.batchNo()).isBlank() ? BusinessIdGenerator.next("TPC") : clean(request.batchNo());
        String remark = clean(request.remark());
        String codePrefix = normalizedGeneratedCodePrefix(request.codePrefix());
        Set<String> generatedCodes = new LinkedHashSet<>();
        int imported = 0;
        int duplicate = 0;
        int attempts = 0;
        int maxAttempts = Math.max(target * 20, 100);
        while (imported < target && attempts < maxAttempts) {
            attempts++;
            String exchangeCode = buildGeneratedExchangeCode(codePrefix);
            if (!generatedCodes.add(exchangeCode)) {
                duplicate++;
                continue;
            }
            TicketPackageCode entity = new TicketPackageCode();
            entity.setPackageId(ticketPackage.getId());
            entity.setExchangeCode(exchangeCode);
            entity.setBatchNo(batchNo);
            entity.setStatus("AVAILABLE");
            entity.setRemark(remark);
            entity.setDeleted(0);
            try {
                codeMapper.insert(entity);
                imported++;
            } catch (DuplicateKeyException exception) {
                duplicate++;
                generatedCodes.remove(exchangeCode);
            }
        }
        if (imported < target) {
            throw new BusinessException(ErrorCode.CONFLICT, "兑换码生成失败,请重试");
        }
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("packageId", ticketPackage.getId());
        result.put("batchNo", batchNo);
        result.put("submittedCount", target);
        result.put("distinctCount", generatedCodes.size());
        result.put("importedCount", imported);
        result.put("duplicateCount", duplicate);
        result.put("stock", stock(ticketPackage.getId()));
        return result;
    }
    @Transactional
    public Map<String, Object> importCodeFile(Long packageId, MultipartFile file, String batchNo, String remark) {
        if (file == null || file.isEmpty()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "兑换码文件不能为空");
@@ -176,12 +280,12 @@
    }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> codes(Long packageId, String status, String batchNo, String keyword, int page, int size) {
    public PageResult<Map<String, Object>> codes(Long packageId, String status, String batchNo, String keyword, String packageKeyword, String merchantKeyword, int page, int size) {
        int safePage = Math.max(page, 1);
        int safeSize = Math.min(Math.max(size, 1), 100);
        String normalizedStatus = normalizeCodeStatusFilter(status);
        Long total = codeMapper.countAdminPage(packageId, normalizedStatus, clean(batchNo), clean(keyword));
        List<Map<String, Object>> records = codeMapper.selectAdminPage(packageId, normalizedStatus, clean(batchNo), clean(keyword), safeSize, (safePage - 1) * safeSize)
        Long total = codeMapper.countAdminPage(packageId, normalizedStatus, clean(batchNo), clean(keyword), clean(packageKeyword), clean(merchantKeyword));
        List<Map<String, Object>> records = codeMapper.selectAdminPage(packageId, normalizedStatus, clean(batchNo), clean(keyword), clean(packageKeyword), clean(merchantKeyword), safeSize, (safePage - 1) * safeSize)
                .stream()
                .map(this::codeView)
                .toList();
@@ -221,22 +325,259 @@
                    .eq("id", current.getIssuedClaimId())
                    .eq("deleted", 0));
        }
        return codeView(codeMapper.selectAdminPage(current.getPackageId(), "", "", current.getExchangeCode(), 1, 0).stream().findFirst().orElse(Map.of()));
        return codeView(codeMapper.selectAdminPage(current.getPackageId(), "", "", current.getExchangeCode(), "", "", 1, 0).stream().findFirst().orElse(Map.of()));
    }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> claims(Long packageId, Long talentId, String status, String keyword, int page, int size) {
    public PageResult<Map<String, Object>> claims(Long packageId, Long talentId, String status, String keyword, String packageKeyword, String talentKeyword, String cardLevel, LocalDate claimedStart, LocalDate claimedEnd, int page, int size) {
        int safePage = Math.max(page, 1);
        int safeSize = Math.min(Math.max(size, 1), 100);
        String normalizedStatus = normalizeClaimStatusFilter(status);
        Long total = claimMapper.countAdminPage(packageId, talentId, normalizedStatus, clean(keyword));
        List<Map<String, Object>> records = claimMapper.selectAdminPage(packageId, talentId, normalizedStatus, clean(keyword), safeSize, (safePage - 1) * safeSize)
        Long total = claimMapper.countAdminPage(packageId, talentId, normalizedStatus, clean(keyword), clean(packageKeyword), clean(talentKeyword), clean(cardLevel), claimedStart, claimedEnd);
        List<Map<String, Object>> records = claimMapper.selectAdminPage(packageId, talentId, normalizedStatus, clean(keyword), clean(packageKeyword), clean(talentKeyword), clean(cardLevel), claimedStart, claimedEnd, safeSize, (safePage - 1) * safeSize)
                .stream()
                .map(this::claimView)
                .toList();
        return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total);
    }
    /**
     * 票包核销记录:只列已核销(status = USED)的领取记录,供后台「电影票管理 → 核销记录」查询与导出。
     *
     * <p>「核销商家」取领取记录上的 merchant_id。核销接口要求核销方必须等于该商户,所以这里不需要
     * 再关联 benefit_verify_record 才能知道是谁核销的。
     */
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> verifications(Long packageId, String talentKeyword, String cardLevel,
                                                         LocalDate claimedStart, LocalDate claimedEnd,
                                                         LocalDate verifiedStart, LocalDate verifiedEnd,
                                                         String merchantKeyword, int page, int size) {
        int safePage = Math.max(page, 1);
        int safeSize = Math.min(Math.max(size, 1), 100);
        Long total = claimMapper.countAdminVerificationPage(packageId, clean(talentKeyword), clean(cardLevel),
                claimedStart, claimedEnd, verifiedStart, verifiedEnd, clean(merchantKeyword));
        List<Map<String, Object>> records = claimMapper.selectAdminVerificationPage(packageId, clean(talentKeyword),
                        clean(cardLevel), claimedStart, claimedEnd, verifiedStart, verifiedEnd, clean(merchantKeyword),
                        safeSize, (safePage - 1) * safeSize)
                .stream()
                .map(this::verificationView)
                .toList();
        return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total);
    }
    private Map<String, Object> verificationView(Map<String, Object> row) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("id", value(row, "id"));
        result.put("claimNo", text(row, "claimNo", "claim_no"));
        result.put("packageId", value(row, "packageId", "package_id"));
        result.put("packageName", text(row, "packageName", "package_name"));
        result.put("merchantNames", text(row, "merchantNames", "merchant_names"));
        result.put("talentName", text(row, "talentName", "talent_name"));
        result.put("talentMobile", text(row, "talentMobile", "talent_mobile"));
        result.put("cardLevel", text(row, "cardLevel", "card_level"));
        result.put("claimedAt", value(row, "createdAt", "created_at"));
        result.put("usedAt", value(row, "usedAt", "used_at"));
        result.put("verifyMerchantName", text(row, "verifyMerchantName", "verify_merchant_name"));
        return result;
    }
    @Transactional(readOnly = true)
    public Map<String, Object> codeDetail(Long codeId) {
        Long id = requireId(codeId, "兑换码ID不能为空");
        Map<String, Object> row = codeMapper.selectAdminDetailById(id);
        if (row == null || row.isEmpty()) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "兑换码不存在");
        }
        return codeDetailView(row);
    }
    @Transactional(readOnly = true)
    public Map<String, Object> validateCode(String exchangeCode, Long packageId, Long merchantId) {
        String codeValue = clean(exchangeCode);
        if (codeValue.isBlank()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "兑换码码值不能为空");
        }
        Map<String, Object> row = codeMapper.selectAdminDetailByExchangeCode(codeValue, packageId);
        if (row == null || row.isEmpty()) {
            return validationResult(false, "兑换码不存在", row);
        }
        String reason = validationReason(row, merchantId);
        if (reason.isBlank() && "AVAILABLE".equals(text(row, "status"))) {
            return validationResult(true, "", row);
        }
        if (reason.isBlank()) {
            reason = codeStatusReason(text(row, "status"));
        }
        return validationResult(false, reason, row);
    }
    @Transactional
    public Map<String, Object> lockForIssue(Long codeId, TicketPackageCodeLockRequest request) {
        Long id = requireId(codeId, "兑换码ID不能为空");
        TicketPackageCode code = codeMapper.selectByIdForUpdate(id);
        if (code == null || intValue(code.getDeleted()) == 1) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "兑换码不存在");
        }
        if (!"AVAILABLE".equals(code.getStatus())) {
            throw new BusinessException(ErrorCode.CONFLICT, "仅可用状态兑换码可锁定");
        }
        validateLockablePackage(packageMapper.selectById(code.getPackageId()));
        ClaimerInfo claimer = resolveClaimer(request.talentId(), request.cardId(), false);
        int locked = codeMapper.lockForIssue(code.getId(), claimer.talentId(), claimer.cardId());
        if (locked != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "兑换码已被领取或状态已变化,请刷新后重试");
        }
        if (!clean(request.remark()).isBlank()) {
            codeMapper.update(null, new UpdateWrapper<TicketPackageCode>()
                    .set("remark", clean(request.remark()))
                    .eq("id", code.getId())
                    .eq("deleted", 0));
        }
        return codeDetailView(codeMapper.selectAdminDetailById(code.getId()));
    }
    @Transactional
    public Map<String, Object> releaseLock(Long codeId) {
        Long id = requireId(codeId, "兑换码ID不能为空");
        TicketPackageCode code = codeMapper.selectByIdForUpdate(id);
        if (code == null || intValue(code.getDeleted()) == 1) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "兑换码不存在");
        }
        if (!"LOCKED".equals(code.getStatus())) {
            throw new BusinessException(ErrorCode.CONFLICT, "仅锁定状态兑换码可解锁");
        }
        int released = codeMapper.releaseLock(id);
        if (released != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "兑换码状态已变化,请刷新后重试");
        }
        return codeDetailView(codeMapper.selectAdminDetailById(id));
    }
    @Transactional
    public Map<String, Object> voidCode(Long codeId, String remark) {
        Long id = requireId(codeId, "兑换码ID不能为空");
        TicketPackageCode code = codeMapper.selectByIdForUpdate(id);
        if (code == null || intValue(code.getDeleted()) == 1) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "兑换码不存在");
        }
        String current = code.getStatus();
        if ("USED".equals(current) || "VOID".equals(current)) {
            throw new BusinessException(ErrorCode.CONFLICT, "已使用或已作废兑换码不能作废");
        }
        UpdateWrapper<TicketPackageCode> update = new UpdateWrapper<TicketPackageCode>()
                .set("status", "VOID")
                .set("remark", clean(remark))
                .eq("id", id)
                .eq("deleted", 0);
        if ("LOCKED".equals(current)) {
            update.set("locked_by_talent_id", null)
                    .set("locked_by_card_id", null)
                    .set("locked_at", null);
        }
        int updated = codeMapper.update(null, update);
        if (updated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "兑换码状态已变化,请刷新后重试");
        }
        if (code.getIssuedClaimId() != null) {
            claimMapper.update(null, new UpdateWrapper<TicketPackageClaim>()
                    .set("status", "CANCELLED")
                    .eq("id", code.getIssuedClaimId())
                    .eq("deleted", 0));
        }
        return codeDetailView(codeMapper.selectAdminDetailById(id));
    }
    @Transactional
    public Map<String, Object> supplementClaim(TicketPackageSupplementRequest request) {
        Long packageId = requireId(request.packageId(), "票包ID不能为空");
        TicketPackage ticketPackage = packageMapper.selectByIdForUpdate(packageId);
        validateLockablePackage(ticketPackage);
        TicketPackageCode code = resolveSupplementCode(request, packageId);
        TicketPackageClaim existing = claimMapper.selectByCodeId(code.getId());
        if (existing != null) {
            return claimAdminView(existing);
        }
        String current = code.getStatus();
        if (!"AVAILABLE".equals(current) && !"LOCKED".equals(current)) {
            throw new BusinessException(ErrorCode.CONFLICT, "仅可用或锁定状态兑换码可补录发放");
        }
        ClaimerInfo claimer = resolveClaimer(request.talentId(), request.cardId(), true);
        LocalDateTime now = LocalDateTime.now();
        if ("AVAILABLE".equals(current)) {
            int locked = codeMapper.lockForIssue(code.getId(), claimer.talentId(), claimer.cardId());
            if (locked != 1) {
                throw new BusinessException(ErrorCode.CONFLICT, "兑换码已被领取,请刷新后重试");
            }
        }
        TicketPackageClaim claim = new TicketPackageClaim();
        claim.setClaimNo(BusinessIdGenerator.next("TPC"));
        claim.setPackageId(ticketPackage.getId());
        claim.setCodeId(code.getId());
        claim.setMerchantId(ticketPackage.getMerchantId());
        claim.setTalentId(claimer.talentId());
        claim.setCardId(claimer.cardId());
        claim.setPrimaryCardId(claimer.primaryCardId());
        claim.setCardNo(claimer.cardNo());
        claim.setCardLevel(claimer.cardLevel());
        claim.setTalentName(claimer.talentName());
        claim.setTalentMobile(claimer.talentMobile());
        claim.setExchangeCode(code.getExchangeCode());
        claim.setStatus("ISSUED");
        claim.setValidEnd(ticketPackage.getValidEnd());
        claim.setIdempotencyKey("MANUAL-" + claim.getClaimNo());
        claim.setDeleted(0);
        try {
            claimMapper.insert(claim);
        } catch (DuplicateKeyException exception) {
            codeMapper.releaseLock(code.getId());
            TicketPackageClaim concurrent = claimMapper.selectByCodeId(code.getId());
            if (concurrent != null) {
                return claimAdminView(concurrent);
            }
            throw new BusinessException(ErrorCode.CONFLICT, "补录请求已提交,请稍后查询发放结果");
        } catch (RuntimeException exception) {
            codeMapper.releaseLock(code.getId());
            throw exception;
        }
        int issued = codeMapper.update(null, new UpdateWrapper<TicketPackageCode>()
                .set("status", "ISSUED")
                .set("issued_claim_id", claim.getId())
                .set("issued_at", now)
                .set("locked_by_talent_id", claimer.talentId())
                .set("locked_by_card_id", claimer.cardId())
                .set("locked_at", now)
                .set("remark", clean(request.remark()))
                .eq("id", code.getId())
                .eq("status", "LOCKED")
                .eq("deleted", 0));
        if (issued != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "兑换码发放失败,请重试");
        }
        return claimAdminView(claim);
    }
    private TicketPackageCode resolveSupplementCode(TicketPackageSupplementRequest request, Long packageId) {
        if (request.codeId() != null) {
            TicketPackageCode code = codeMapper.selectByIdForUpdate(request.codeId());
            if (code == null || intValue(code.getDeleted()) == 1) {
                throw new BusinessException(ErrorCode.NOT_FOUND, "兑换码不存在");
            }
            if (!packageId.equals(code.getPackageId())) {
                throw new BusinessException(ErrorCode.CONFLICT, "兑换码不属于该票包");
            }
            return code;
        }
        String codeValue = clean(request.exchangeCode());
        if (codeValue.isBlank()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "兑换码ID与兑换码码值至少填一个");
        }
        TicketPackageCode code = codeMapper.selectByExchangeCodeForUpdate(codeValue, packageId);
        if (code == null || intValue(code.getDeleted()) == 1) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "兑换码不存在");
        }
        return code;
    }
    private List<String> readExcelCodes(MultipartFile file) {
        List<String> codes = new ArrayList<>();
        DataFormatter formatter = new DataFormatter();
@@ -277,13 +618,28 @@
        String text = clean(value).toUpperCase(Locale.ROOT);
        return List.of("兑换码", "券码", "码", "EXCHANGE_CODE", "CODE").contains(text);
    }
    private String normalizedGeneratedCodePrefix(String codePrefix) {
        String prefix = clean(codePrefix).replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT);
        if (prefix.isBlank()) {
            return "EX";
        }
        return prefix.length() > 16 ? prefix.substring(0, 16) : prefix;
    }
    private String buildGeneratedExchangeCode(String codePrefix) {
        return normalizedGeneratedCodePrefix(codePrefix) + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT);
    }
    private void apply(TicketPackage entity, TicketPackageSaveRequest request) {
        entity.setPackageName(cleanRequired(request.packageName(), "票包名称不能为空"));
        entity.setMerchantId(request.merchantId());
        List<Long> merchantIds = normalizedMerchantIds(request);
        entity.setTotalQuantity(Math.max(request.totalQuantity() == null ? 0 : request.totalQuantity(), 0));
        entity.setMerchantId(merchantIds.isEmpty() ? null : merchantIds.get(0));
        entity.setCoverUrl(clean(request.coverUrl()));
        entity.setTalentLevels(joinTalentLevels(request.talentLevels()));
        entity.setTalentLevels(joinTalentLevels(levelsFromRules(request)));
        entity.setClaimPrice(request.claimPrice() == null ? BigDecimal.ZERO : request.claimPrice());
        entity.setPerUserLimit(request.perUserLimit() == null ? 1 : request.perUserLimit());
        entity.setPerUserLimit(defaultPerUserLimit(request));
        entity.setValidStart(request.validStart());
        entity.setValidEnd(request.validEnd());
        if (entity.getValidStart() != null && entity.getValidEnd() != null && entity.getValidEnd().isBefore(entity.getValidStart())) {
@@ -301,10 +657,19 @@
        result.put("id", value(row, "id"));
        result.put("packageNo", text(row, "packageNo", "package_no"));
        result.put("packageName", text(row, "packageName", "package_name"));
        result.put("totalQuantity", intValue(value(row, "totalQuantity", "total_quantity")));
        result.put("merchantId", value(row, "merchantId", "merchant_id"));
        result.put("merchantName", text(row, "merchantName", "merchant_name"));
        List<Long> merchantIds = splitLongs(text(row, "merchantIdsText", "merchant_ids_text"));
        Long legacyMerchantId = longValue(row, "merchantId", "merchant_id");
        if (merchantIds.isEmpty() && legacyMerchantId != null) {
            merchantIds = List.of(legacyMerchantId);
        }
        result.put("merchantIds", merchantIds);
        result.put("merchantNames", text(row, "merchantNames", "merchant_names"));
        result.put("coverUrl", text(row, "coverUrl", "cover_url"));
        result.put("talentLevels", splitLevels(text(row, "talentLevels", "talent_levels")));
        result.put("levelRules", parseLevelRules(text(row, "levelRulesText", "level_rules_text")));
        result.put("claimPrice", value(row, "claimPrice", "claim_price"));
        result.put("perUserLimit", intValue(value(row, "perUserLimit", "per_user_limit")));
        result.put("validStart", value(row, "validStart", "valid_start"));
@@ -320,6 +685,8 @@
        result.put("issuedCodes", intValue(value(row, "issuedCodes", "issued_codes")));
        result.put("usedCodes", intValue(value(row, "usedCodes", "used_codes")));
        result.put("voidCodes", intValue(value(row, "voidCodes", "void_codes")));
        result.put("claimedCount", intValue(value(row, "claimedCount", "claimed_count")));
        result.put("remainingCount", Math.max(intValue(value(row, "totalQuantity", "total_quantity")) - intValue(result.get("claimedCount")), 0));
        result.put("createdAt", value(row, "createdAt", "created_at"));
        result.put("updatedAt", value(row, "updatedAt", "updated_at"));
        return result;
@@ -329,13 +696,19 @@
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("id", value(row, "id"));
        result.put("packageId", value(row, "packageId", "package_id"));
        result.put("codeId", value(row, "id"));
        result.put("packageNo", text(row, "packageNo", "package_no"));
        result.put("packageName", text(row, "packageName", "package_name"));
        result.put("merchantName", text(row, "merchantName", "merchant_name"));
        result.put("merchantNames", text(row, "merchantNames", "merchant_names"));
        result.put("exchangeCode", text(row, "exchangeCode", "exchange_code"));
        result.put("batchNo", text(row, "batchNo", "batch_no"));
        result.put("status", text(row, "status"));
        result.put("statusName", codeStatusName(text(row, "status")));
        result.put("lockedByTalentId", value(row, "lockedByTalentId", "locked_by_talent_id"));
        result.put("lockedByCardId", value(row, "lockedByCardId", "locked_by_card_id"));
        result.put("lockedTalentName", text(row, "lockedTalentName", "locked_talent_name"));
        result.put("lockedTalentMobile", text(row, "lockedTalentMobile", "locked_talent_mobile"));
        result.put("lockedAt", value(row, "lockedAt", "locked_at"));
        result.put("issuedClaimId", value(row, "issuedClaimId", "issued_claim_id"));
        result.put("issuedAt", value(row, "issuedAt", "issued_at"));
@@ -354,6 +727,7 @@
        result.put("packageName", text(row, "packageName", "package_name"));
        result.put("merchantId", value(row, "merchantId", "merchant_id"));
        result.put("merchantName", text(row, "merchantName", "merchant_name"));
        result.put("merchantNames", text(row, "merchantNames", "merchant_names"));
        result.put("talentId", value(row, "talentId", "talent_id"));
        result.put("cardId", value(row, "cardId", "card_id"));
        result.put("cardNo", text(row, "cardNo", "card_no"));
@@ -363,7 +737,14 @@
        result.put("exchangeCode", text(row, "exchangeCode", "exchange_code"));
        result.put("status", text(row, "status"));
        result.put("statusName", claimStatusName(text(row, "status")));
        result.put("claimStatus", text(row, "status"));
        result.put("claimStatusName", claimStatusName(text(row, "status")));
        result.put("useStatus", "USED".equalsIgnoreCase(text(row, "status")) ? "USED" : "ACTIVE");
        result.put("useStatusName", "USED".equalsIgnoreCase(text(row, "status")) ? "已使用" : "未使用");
        result.put("claimedAt", value(row, "createdAt", "created_at"));
        result.put("validEnd", value(row, "validEnd", "valid_end"));
        result.put("source", claimSource(text(row, "idempotencyKey")));
        result.put("sourceName", "MANUAL".equals(claimSource(text(row, "idempotencyKey"))) ? "后台补录" : "线上领取");
        result.put("usedAt", value(row, "usedAt", "used_at"));
        result.put("createdAt", value(row, "createdAt", "created_at"));
        result.put("updatedAt", value(row, "updatedAt", "updated_at"));
@@ -391,17 +772,149 @@
        return ticketPackage;
    }
    private void validateMerchant(Long merchantId) {
        if (merchantId == null) {
            return;
    private List<Long> validateMerchants(TicketPackageSaveRequest request) {
        List<Long> merchantIds = normalizedMerchantIds(request);
        for (Long merchantId : merchantIds) {
            BenefitMerchant merchant = merchantMapper.selectOne(new QueryWrapper<BenefitMerchant>()
                    .eq("id", merchantId)
                    .eq("deleted", 0)
                    .last("limit 1"));
            if (merchant == null) {
                throw new BusinessException(ErrorCode.NOT_FOUND, "指定商家不存在");
            }
        }
        BenefitMerchant merchant = merchantMapper.selectOne(new QueryWrapper<BenefitMerchant>()
                .eq("id", merchantId)
                .eq("deleted", 0)
                .last("limit 1"));
        if (merchant == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "指定商家不存在");
        return merchantIds;
    }
    private List<Long> normalizedMerchantIds(TicketPackageSaveRequest request) {
        List<Long> result = new ArrayList<>();
        if (request.merchantIds() != null) {
            for (Long merchantId : request.merchantIds()) {
                if (merchantId != null && merchantId > 0 && !result.contains(merchantId)) {
                    result.add(merchantId);
                }
            }
        }
        if (result.isEmpty() && request.merchantId() != null && request.merchantId() > 0) {
            result.add(request.merchantId());
        }
        return result;
    }
    private void savePackageMerchants(Long packageId, List<Long> merchantIds) {
        UpdateWrapper<TicketPackageMerchant> clear = new UpdateWrapper<TicketPackageMerchant>()
                .set("deleted", 1)
                .eq("package_id", packageId)
                .eq("deleted", 0);
        if (!merchantIds.isEmpty()) {
            clear.notIn("merchant_id", merchantIds);
        }
        packageMerchantMapper.update(null, clear);
        int sortNo = 0;
        for (Long merchantId : merchantIds) {
            TicketPackageMerchant current = packageMerchantMapper.selectOne(new QueryWrapper<TicketPackageMerchant>()
                    .eq("package_id", packageId)
                    .eq("merchant_id", merchantId)
                    .last("limit 1"));
            if (current == null) {
                TicketPackageMerchant row = new TicketPackageMerchant();
                row.setPackageId(packageId);
                row.setMerchantId(merchantId);
                row.setSortNo(sortNo++);
                row.setDeleted(0);
                packageMerchantMapper.insert(row);
            } else {
                packageMerchantMapper.update(null, new UpdateWrapper<TicketPackageMerchant>()
                        .set("sort_no", sortNo++)
                        .set("deleted", 0)
                        .eq("id", current.getId()));
            }
        }
    }
    private void saveLevelRules(Long packageId, TicketPackageSaveRequest request) {
        List<TicketPackageSaveRequest.TicketPackageLevelRuleRequest> rules = levelRules(request);
        List<String> ruleLevels = rules.stream()
                .map(rule -> normalizeCardLevel(rule.talentLevel()))
                .filter(level -> !level.isBlank())
                .distinct()
                .toList();
        UpdateWrapper<TicketPackageLevelRule> clear = new UpdateWrapper<TicketPackageLevelRule>()
                .set("deleted", 1)
                .eq("package_id", packageId)
                .eq("deleted", 0);
        if (!ruleLevels.isEmpty()) {
            clear.notIn("talent_level", ruleLevels);
        }
        levelRuleMapper.update(null, clear);
        for (TicketPackageSaveRequest.TicketPackageLevelRuleRequest rule : rules) {
            String level = normalizeCardLevel(rule.talentLevel());
            if (!CARD_LEVELS.contains(level)) {
                throw new BusinessException(ErrorCode.BAD_REQUEST, "适用人才卡等级仅支持卓越人才卡、杰出人才卡、菁英人才卡");
            }
            int enabled = rule.enabled() == null ? 1 : normalizeStatus(rule.enabled());
            int claimableCount = Math.max(rule.claimableCount() == null ? 1 : rule.claimableCount(), 1);
            String resetCycle = normalizeResetCycle(rule.resetCycle());
            TicketPackageLevelRule current = levelRuleMapper.selectOne(new QueryWrapper<TicketPackageLevelRule>()
                    .eq("package_id", packageId)
                    .eq("talent_level", level)
                    .last("limit 1"));
            if (current == null) {
                TicketPackageLevelRule entity = new TicketPackageLevelRule();
                entity.setPackageId(packageId);
                entity.setTalentLevel(level);
                entity.setEnabled(enabled);
                entity.setClaimableCount(claimableCount);
                entity.setResetCycle(resetCycle);
                entity.setDeleted(0);
                levelRuleMapper.insert(entity);
                continue;
            }
            TicketPackageLevelRule entity = new TicketPackageLevelRule();
            entity.setEnabled(enabled);
            entity.setClaimableCount(claimableCount);
            entity.setResetCycle(resetCycle);
            entity.setDeleted(0);
            levelRuleMapper.update(entity, new UpdateWrapper<TicketPackageLevelRule>().eq("id", current.getId()));
        }
    }
    private List<TicketPackageSaveRequest.TicketPackageLevelRuleRequest> levelRules(TicketPackageSaveRequest request) {
        if (request.levelRules() != null && !request.levelRules().isEmpty()) {
            return request.levelRules();
        }
        List<String> levels = request.talentLevels() == null || request.talentLevels().isEmpty() ? CARD_LEVELS : request.talentLevels();
        List<TicketPackageSaveRequest.TicketPackageLevelRuleRequest> result = new ArrayList<>();
        for (String level : CARD_LEVELS) {
            int enabled = levels.stream().anyMatch(item -> normalizeCardLevel(item).equals(level)) ? 1 : 0;
            result.add(new TicketPackageSaveRequest.TicketPackageLevelRuleRequest(level, enabled,
                    request.perUserLimit() == null ? 1 : request.perUserLimit(), "每天"));
        }
        return result;
    }
    private List<String> levelsFromRules(TicketPackageSaveRequest request) {
        List<String> result = new ArrayList<>();
        for (TicketPackageSaveRequest.TicketPackageLevelRuleRequest rule : levelRules(request)) {
            if (rule.enabled() == null || rule.enabled() == 1) {
                String level = normalizeCardLevel(rule.talentLevel());
                if (!result.contains(level)) {
                    result.add(level);
                }
            }
        }
        return result;
    }
    private int defaultPerUserLimit(TicketPackageSaveRequest request) {
        int limit = request.perUserLimit() == null ? 1 : request.perUserLimit();
        for (TicketPackageSaveRequest.TicketPackageLevelRuleRequest rule : levelRules(request)) {
            if (rule.enabled() == null || rule.enabled() == 1) {
                limit = Math.max(rule.claimableCount() == null ? limit : rule.claimableCount(), 1);
                break;
            }
        }
        return Math.max(limit, 1);
    }
    private String joinTalentLevels(List<String> levels) {
@@ -414,6 +927,7 @@
            if (value.isBlank()) {
                continue;
            }
            value = normalizeCardLevel(value);
            if (!CARD_LEVELS.contains(value)) {
                throw new BusinessException(ErrorCode.BAD_REQUEST, "适用人才卡等级仅支持卓越人才卡、杰出人才卡、菁英人才卡");
            }
@@ -430,6 +944,106 @@
            return List.of();
        }
        return List.of(text.split("[、,,]"));
    }
    private List<Long> splitLongs(String value) {
        String text = clean(value);
        if (text.isBlank()) {
            return List.of();
        }
        List<Long> result = new ArrayList<>();
        for (String part : text.split(",")) {
            try {
                Long id = Long.valueOf(part.trim());
                if (id > 0 && !result.contains(id)) {
                    result.add(id);
                }
            } catch (NumberFormatException ignored) {
                // Ignore dirty historical data in aggregate text.
            }
        }
        return result;
    }
    private List<Map<String, Object>> parseLevelRules(String value) {
        Map<String, Map<String, Object>> byLevel = new HashMap<>();
        String text = clean(value);
        if (!text.isBlank()) {
            for (String item : text.split(";;")) {
                String[] parts = item.split("\\|", -1);
                if (parts.length < 4) {
                    continue;
                }
                String level = normalizeCardLevel(parts[0]);
                if (!CARD_LEVELS.contains(level)) {
                    continue;
                }
                Map<String, Object> row = new LinkedHashMap<>();
                row.put("talentLevel", level);
                row.put("enabled", parseInt(parts[1], 1));
                row.put("claimableCount", Math.max(parseInt(parts[2], 1), 1));
                row.put("resetCycle", normalizeResetCycle(parts[3]));
                byLevel.put(level, row);
            }
        }
        List<Map<String, Object>> result = new ArrayList<>();
        for (String level : CARD_LEVELS) {
            result.add(byLevel.getOrDefault(level, defaultLevelRule(level)));
        }
        return result;
    }
    private Map<String, Object> defaultLevelRule(String level) {
        Map<String, Object> row = new LinkedHashMap<>();
        row.put("talentLevel", level);
        row.put("enabled", 1);
        row.put("claimableCount", 1);
        row.put("resetCycle", "每天");
        return row;
    }
    private int parseInt(String value, int fallback) {
        try {
            return Integer.parseInt(clean(value));
        } catch (NumberFormatException exception) {
            return fallback;
        }
    }
    private String normalizeCardLevel(String level) {
        String value = clean(level);
        String upper = value.toUpperCase(Locale.ROOT);
        if (upper.contains("EXCELLENT") || value.contains("卓越")) {
            return "卓越人才卡";
        }
        if (upper.contains("OUTSTANDING") || value.contains("杰出")) {
            return "杰出人才卡";
        }
        if (upper.contains("ELITE") || value.contains("菁英")) {
            return "菁英人才卡";
        }
        return value;
    }
    private String normalizeResetCycle(String resetCycle) {
        String value = clean(resetCycle);
        if (value.isBlank()) {
            return "每天";
        }
        String upper = value.toUpperCase(Locale.ROOT);
        if (upper.contains("DAY") || value.contains("天") || value.contains("日")) {
            return "每天";
        }
        if (upper.contains("WEEK") || value.contains("周") || value.contains("星期")) {
            return "每周";
        }
        if (upper.contains("MONTH") || value.contains("月")) {
            return "每月";
        }
        if (upper.contains("YEAR") || upper.contains("ANNUAL") || value.contains("年")) {
            return "每年";
        }
        return value;
    }
    private int normalizeStatus(Integer status) {
@@ -503,6 +1117,214 @@
        };
    }
    private void validateLockablePackage(TicketPackage ticketPackage) {
        if (ticketPackage == null || intValue(ticketPackage.getDeleted()) == 1) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "票包不存在");
        }
        if (intValue(ticketPackage.getStatus()) != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "票包当前不可发放");
        }
        LocalDate today = LocalDate.now();
        if (ticketPackage.getValidStart() != null && ticketPackage.getValidStart().isAfter(today)) {
            throw new BusinessException(ErrorCode.CONFLICT, "票包尚未开始发放");
        }
        if (ticketPackage.getValidEnd() != null && ticketPackage.getValidEnd().isBefore(today)) {
            throw new BusinessException(ErrorCode.CONFLICT, "票包已过期");
        }
    }
    private ClaimerInfo resolveClaimer(Long talentId, Long cardId, boolean lock) {
        if ((talentId == null || talentId <= 0) && (cardId == null || cardId <= 0)) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "持卡人ID或人才卡ID不能为空");
        }
        Map<String, Object> source = lock
                ? claimMapper.selectActiveCardSnapshotForUpdate(talentId, cardId)
                : claimMapper.selectActiveCardSnapshot(talentId, cardId);
        if (source == null || source.isEmpty()) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "有效人才卡不存在");
        }
        String cardType = text(source, "cardType", "card_type").toUpperCase(Locale.ROOT);
        if ("PERSONAL".equals(cardType) || cardType.isBlank()) {
            return new ClaimerInfo(
                    longValue(source, "talentId", "talent_id"),
                    longValue(source, "cardId", "card_id"),
                    text(source, "cardNo", "card_no"),
                    text(source, "cardLevel", "card_level"),
                    text(source, "talentName", "talent_name"),
                    text(source, "talentMobile", "talent_mobile"),
                    longValue(source, "cardId", "card_id"),
                    text(source, "cardLevel", "card_level")
            );
        }
        Map<String, Object> primary = lock
                ? claimMapper.selectPrimaryCardByFamilyCardForUpdate(longValue(source, "cardId", "card_id"))
                : claimMapper.selectPrimaryCardByFamilyCard(longValue(source, "cardId", "card_id"));
        if (primary == null || primary.isEmpty()) {
            throw new BusinessException(ErrorCode.CONFLICT, "亲属附属卡未找到有效主卡");
        }
        return new ClaimerInfo(
                longValue(source, "talentId", "talent_id"),
                longValue(source, "cardId", "card_id"),
                text(source, "cardNo", "card_no"),
                text(primary, "cardLevel", "card_level"),
                text(source, "talentName", "talent_name"),
                text(source, "talentMobile", "talent_mobile"),
                longValue(primary, "cardId", "card_id"),
                text(primary, "cardLevel", "card_level")
        );
    }
    private Map<String, Object> codeDetailView(Map<String, Object> row) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("id", value(row, "id"));
        result.put("packageId", value(row, "packageId"));
        result.put("packageNo", text(row, "packageNo"));
        result.put("packageName", text(row, "packageName"));
        result.put("merchantId", value(row, "merchantId"));
        result.put("merchantName", text(row, "merchantName"));
        result.put("exchangeCode", text(row, "exchangeCode"));
        result.put("batchNo", text(row, "batchNo"));
        result.put("validStart", value(row, "validStart"));
        result.put("validEnd", value(row, "validEnd"));
        result.put("status", text(row, "status"));
        result.put("statusName", codeStatusName(text(row, "status")));
        result.put("lockedByTalentId", value(row, "lockedByTalentId"));
        result.put("lockedByCardId", value(row, "lockedByCardId"));
        result.put("lockedAt", value(row, "lockedAt"));
        result.put("issuedClaimId", value(row, "issuedClaimId"));
        result.put("issuedAt", value(row, "issuedAt"));
        result.put("usedAt", value(row, "usedAt"));
        result.put("claimNo", text(row, "claimNo"));
        result.put("claimStatus", text(row, "claimStatus"));
        result.put("claimTalentId", value(row, "claimTalentId"));
        result.put("claimTalentName", text(row, "claimTalentName"));
        result.put("claimTalentMobile", text(row, "claimTalentMobile"));
        result.put("claimCardNo", text(row, "claimCardNo"));
        result.put("claimCardLevel", text(row, "claimCardLevel"));
        result.put("remark", text(row, "remark"));
        result.put("createdAt", value(row, "createdAt"));
        result.put("updatedAt", value(row, "updatedAt"));
        return result;
    }
    private Map<String, Object> validationResult(boolean claimable, String reason, Map<String, Object> row) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("found", row != null && !row.isEmpty());
        result.put("claimable", claimable);
        result.put("reason", reason);
        if (row != null && !row.isEmpty()) {
            result.put("codeId", value(row, "id"));
            result.put("exchangeCode", text(row, "exchangeCode"));
            result.put("packageId", value(row, "packageId"));
            result.put("packageNo", text(row, "packageNo"));
            result.put("packageName", text(row, "packageName"));
            result.put("merchantId", value(row, "merchantId"));
            result.put("merchantName", text(row, "merchantName"));
            result.put("validStart", value(row, "validStart"));
            result.put("validEnd", value(row, "validEnd"));
            result.put("status", text(row, "status"));
            result.put("statusName", codeStatusName(text(row, "status")));
            result.put("batchNo", text(row, "batchNo"));
            result.put("claimNo", text(row, "claimNo"));
            result.put("claimStatus", text(row, "claimStatus"));
            result.put("claimTalentName", text(row, "claimTalentName"));
            result.put("claimTalentMobile", text(row, "claimTalentMobile"));
            result.put("claimCardNo", text(row, "claimCardNo"));
            result.put("usedAt", value(row, "claimUsedAt"));
        }
        return result;
    }
    private String validationReason(Map<String, Object> row, Long merchantId) {
        if (intValue(value(row, "packageStatus")) != 1) {
            return "票包已停用";
        }
        LocalDate today = LocalDate.now();
        LocalDate validStart = toLocalDate(value(row, "validStart"));
        LocalDate validEnd = toLocalDate(value(row, "validEnd"));
        if (validStart != null && validStart.isAfter(today)) {
            return "票包尚未开始领取";
        }
        if (validEnd != null && validEnd.isBefore(today)) {
            return "票包已过期";
        }
        Long rowMerchantId = longValue(row, "merchantId");
        if (merchantId != null && rowMerchantId != null && !merchantId.equals(rowMerchantId)) {
            return "兑换码不适用于该商户";
        }
        return "";
    }
    private String codeStatusReason(String status) {
        return switch (clean(status).toUpperCase(Locale.ROOT)) {
            case "LOCKED" -> "兑换码已锁定";
            case "ISSUED" -> "兑换码已发放";
            case "USED" -> "兑换码已使用";
            case "VOID" -> "兑换码已作废";
            default -> "兑换码当前不可领取";
        };
    }
    private Map<String, Object> claimAdminView(TicketPackageClaim claim) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("id", claim.getId());
        result.put("claimNo", claim.getClaimNo());
        result.put("packageId", claim.getPackageId());
        result.put("codeId", claim.getCodeId());
        result.put("merchantId", claim.getMerchantId());
        result.put("talentId", claim.getTalentId());
        result.put("cardId", claim.getCardId());
        result.put("primaryCardId", claim.getPrimaryCardId());
        result.put("cardNo", claim.getCardNo());
        result.put("cardLevel", claim.getCardLevel());
        result.put("talentName", claim.getTalentName());
        result.put("talentMobile", claim.getTalentMobile());
        result.put("exchangeCode", claim.getExchangeCode());
        result.put("status", claim.getStatus());
        result.put("statusName", claimStatusName(claim.getStatus()));
        result.put("validEnd", claim.getValidEnd());
        result.put("source", claimSource(claim.getIdempotencyKey()));
        result.put("sourceName", "MANUAL".equals(claimSource(claim.getIdempotencyKey())) ? "后台补录" : "线上领取");
        result.put("usedAt", claim.getUsedAt());
        result.put("createdAt", claim.getCreatedAt());
        result.put("updatedAt", claim.getUpdatedAt());
        return result;
    }
    private String claimSource(String idempotencyKey) {
        return clean(idempotencyKey).startsWith("MANUAL-") ? "MANUAL" : "CLAIM";
    }
    private LocalDate toLocalDate(Object value) {
        if (value instanceof LocalDate date) {
            return date;
        }
        if (value instanceof java.sql.Date date) {
            return date.toLocalDate();
        }
        if (value instanceof java.sql.Timestamp timestamp) {
            return timestamp.toLocalDateTime().toLocalDate();
        }
        return null;
    }
    private Long longValue(Map<String, Object> row, String... keys) {
        Object value = value(row, keys);
        return value instanceof Number number ? number.longValue() : null;
    }
    private record ClaimerInfo(
            Long talentId,
            Long cardId,
            String cardNo,
            String cardLevel,
            String talentName,
            String talentMobile,
            Long primaryCardId,
            String primaryCardLevel
    ) {
    }
    private Long requireId(Long id, String message) {
        if (id == null || id <= 0) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, message);
@@ -539,4 +1361,4 @@
    private String clean(String value) {
        return value == null ? "" : value.trim();
    }
}
}