wenminjie
2026-08-03 ddc84625dcdb22a1ec2407f6adc7eb2e7a839ef0
小程序一些接口,加了mybatisplus、swagger
61个文件已添加
5个文件已修改
4697 ■■■■■ 已修改文件
talent-card-server/pom.xml 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/TalentCardServerApplication.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/DependentCardApplyAdminController.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/ServiceAppointmentAdminController.java 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/auth/AdminAuthInterceptor.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/auth/AdminAuthService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/BenefitVerificationController.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/BenefitVerificationCodeController.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/DependentCardApplyController.java 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/ServiceAppointmentApplicationController.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/common/SensitiveDataCrypto.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/config/BenefitVerificationExceptionHandler.java 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/config/ServiceAppointmentExceptionHandler.java 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/config/SwaggerConfig.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitCinemaCoupon.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitCinemaCouponClaim.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitFamilyCardApplication.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitFreeStayVerificationCode.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitFreeStayVoucher.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitMerchant.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitMerchantBenefitRule.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitMerchantProduct.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitMovieTicketRule.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitOrder.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitOrderDynamicVerificationCode.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitTalentCard.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitVerifyRecord.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/entity/ChildInformation.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/entity/DependentCardApply.java 108 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/entity/DependentCardApplyStatusLog.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/entity/ServiceApplication.java 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitCinemaCouponClaimMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitCinemaCouponMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitFamilyCardApplicationMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitFreeStayVerificationCodeMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitFreeStayVoucherMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitMerchantBenefitRuleMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitMerchantMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitMerchantProductMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitMovieTicketRuleMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitOrderDynamicVerificationCodeMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitOrderMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitTalentCardMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitVerifyRecordMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/ChildInformationMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/DependentCardApplyMapper.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/DependentCardApplyStatusLogMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/ServiceApplicationMapper.java 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/request/BenefitVerificationCodeRequest.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/request/BenefitVerificationRequest.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/request/DependentCardApplyRequest.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/request/DependentCardAuditRequest.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/request/DependentCardUnbindRequest.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/request/ServiceAppointmentStatusUpdateRequest.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/request/ServiceAppointmentSubmitRequest.java 156 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/response/DependentCardApplyAdminResponse.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/response/DependentCardApplyItemResponse.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/response/DependentCardApplyResponse.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/response/DependentCardApplyStatusLogResponse.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/BenefitQuotaService.java 459 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationCodeService.java 457 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationService.java 508 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/DependentCardApplyAdminService.java 185 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/DependentCardApplyService.java 192 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/ServiceAppointmentAdminService.java 407 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/ServiceAppointmentApplicationService.java 583 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/pom.xml
@@ -39,6 +39,16 @@
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot4-starter</artifactId>
            <version>3.5.17</version>
        </dependency>
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>3.1.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
talent-card-server/src/main/java/com/talentcard/server/TalentCardServerApplication.java
@@ -3,6 +3,7 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@@ -11,7 +12,9 @@
public class TalentCardServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(TalentCardServerApplication.class, args);
        ConfigurableApplicationContext context = SpringApplication.run(TalentCardServerApplication.class, args);
        String port = context.getEnvironment().getProperty("local.server.port", "8080");
        System.out.printf("Swagger UI: http://localhost:%s/swagger-ui/index.html%n", port);
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/admin/DependentCardApplyAdminController.java
New file
@@ -0,0 +1,74 @@
package com.talentcard.server.business.admin;
import java.util.List;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.admin.auth.AdminAuthInterceptor;
import com.talentcard.server.common.ApiResponse;
import com.talentcard.server.request.DependentCardAuditRequest;
import com.talentcard.server.response.DependentCardApplyAdminResponse;
import com.talentcard.server.response.DependentCardApplyStatusLogResponse;
import com.talentcard.server.service.DependentCardApplyAdminService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "管理后台-家属附属卡审核", description = "家属附属卡申请查询、审核和状态记录")
@RestController
@RequestMapping("/api/admin/dependent-card-applies")
public class DependentCardApplyAdminController {
    private final DependentCardApplyAdminService service;
    public DependentCardApplyAdminController(DependentCardApplyAdminService service) {
        this.service = service;
    }
    @Operation(summary = "分页查询家属附属卡申请")
    @GetMapping
    public ApiResponse<PageResult<DependentCardApplyAdminResponse>> page(
            @RequestParam(required = false) Long talentCardId,
            @RequestParam(required = false) Integer status,
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "10") int size
    ) {
        return ApiResponse.success(service.page(talentCardId, status, page, size));
    }
    @Operation(summary = "查询家属附属卡申请详情")
    @GetMapping("/{id}")
    public ApiResponse<DependentCardApplyAdminResponse> detail(@PathVariable Long id) {
        return ApiResponse.success(service.detail(id));
    }
    @Operation(summary = "审核家属附属卡申请", description = "仅待审核申请可以变更为通过或驳回")
    @ApiResponses({
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "审核成功"),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "审核参数不正确"),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "申请不存在"),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "状态冲突或绑定名额已满")
    })
    @PatchMapping("/{id}/audit")
    public ApiResponse<DependentCardApplyAdminResponse> audit(
            @PathVariable Long id,
            @Valid @RequestBody DependentCardAuditRequest request,
            @RequestAttribute(AdminAuthInterceptor.OPERATOR_ID_ATTRIBUTE) Long operatorId
    ) {
        return ApiResponse.success(service.audit(id, request, operatorId));
    }
    @Operation(summary = "查询家属附属卡申请状态记录")
    @GetMapping("/{id}/status-logs")
    public ApiResponse<List<DependentCardApplyStatusLogResponse>> statusLogs(@PathVariable Long id) {
        return ApiResponse.success(service.statusLogs(id));
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/admin/ServiceAppointmentAdminController.java
New file
@@ -0,0 +1,93 @@
package com.talentcard.server.business.admin;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.admin.auth.AdminAuthInterceptor;
import com.talentcard.server.common.ApiResponse;
import com.talentcard.server.request.ServiceAppointmentStatusUpdateRequest;
import com.talentcard.server.service.ServiceAppointmentAdminService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "后台-预约申请", description = "后台查看、处理、导出体检、医院快捷通道、酒店、配偶就业、学生托管预约申请")
@RestController
@RequestMapping("/api/admin/service-appointment-applications")
public class ServiceAppointmentAdminController {
    private final ServiceAppointmentAdminService service;
    public ServiceAppointmentAdminController(ServiceAppointmentAdminService service) {
        this.service = service;
    }
    @Operation(summary = "预约申请列表", description = "分页查看各类预约申请,支持类型、状态、人才、服务机构和提交时间筛选")
    @GetMapping
    public ApiResponse<PageResult<Map<String, Object>>> page(
            @Parameter(description = "预约类型") @RequestParam(required = false) String applicationType,
            @Parameter(description = "人才/申请人/手机号/卡号/申请编号关键词") @RequestParam(required = false) String keyword,
            @Parameter(description = "服务机构关键词") @RequestParam(required = false) String serviceKeyword,
            @Parameter(description = "人才卡等级") @RequestParam(required = false) String cardLevel,
            @Parameter(description = "处理状态") @RequestParam(required = false) String status,
            @Parameter(description = "提交开始日期,格式yyyy-MM-dd") @RequestParam(required = false) String submittedStart,
            @Parameter(description = "提交结束日期,格式yyyy-MM-dd") @RequestParam(required = false) String submittedEnd,
            @Parameter(description = "页码") @RequestParam(defaultValue = "1") int page,
            @Parameter(description = "每页条数") @RequestParam(defaultValue = "10") int size
    ) {
        return ApiResponse.success(service.page(applicationType, keyword, serviceKeyword, cardLevel, status, submittedStart, submittedEnd, page, size));
    }
    @Operation(summary = "预约申请详情", description = "查看预约申请完整信息,包含服务快照、申请人、配偶或子女信息")
    @GetMapping("/{id}")
    public ApiResponse<Map<String, Object>> detail(@PathVariable Long id) {
        return ApiResponse.success(service.detail(id));
    }
    @Operation(summary = "处理预约申请", description = "维护处理状态和处理备注;学生托管审核通过/不通过时同步更新子女信息审核状态")
    @PatchMapping("/{id}/status")
    public ApiResponse<Map<String, Object>> updateStatus(
            @PathVariable Long id,
            @Valid @RequestBody ServiceAppointmentStatusUpdateRequest request,
            @RequestAttribute(value = AdminAuthInterceptor.OPERATOR_ID_ATTRIBUTE, required = false) Long operatorId
    ) {
        return ApiResponse.success(service.updateStatus(id, request, operatorId));
    }
    @Operation(summary = "导出预约申请", description = "按当前筛选条件导出预约申请Excel")
    @GetMapping(value = "/export", produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
    public void export(
            @Parameter(description = "预约类型") @RequestParam(required = false) String applicationType,
            @Parameter(description = "人才/申请人/手机号/卡号/申请编号关键词") @RequestParam(required = false) String keyword,
            @Parameter(description = "服务机构关键词") @RequestParam(required = false) String serviceKeyword,
            @Parameter(description = "人才卡等级") @RequestParam(required = false) String cardLevel,
            @Parameter(description = "处理状态") @RequestParam(required = false) String status,
            @Parameter(description = "提交开始日期,格式yyyy-MM-dd") @RequestParam(required = false) String submittedStart,
            @Parameter(description = "提交结束日期,格式yyyy-MM-dd") @RequestParam(required = false) String submittedEnd,
            HttpServletResponse response
    ) throws IOException {
        byte[] data = service.exportExcel(applicationType, keyword, serviceKeyword, cardLevel, status, submittedStart, submittedEnd);
        String filename = URLEncoder.encode("预约申请列表.xlsx", StandardCharsets.UTF_8).replace("+", "%20");
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.setContentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet").toString());
        response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + filename);
        response.setContentLength(data.length);
        response.getOutputStream().write(data);
        response.flushBuffer();
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/admin/auth/AdminAuthInterceptor.java
@@ -8,6 +8,8 @@
@Component
public class AdminAuthInterceptor implements HandlerInterceptor {
    public static final String OPERATOR_ID_ATTRIBUTE = "authenticatedAdminOperatorId";
    private final AdminAuthService service;
    public AdminAuthInterceptor(AdminAuthService service) {
@@ -16,7 +18,8 @@
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        service.authenticate(request.getHeader(HttpHeaders.AUTHORIZATION));
        Long operatorId = service.authenticate(request.getHeader(HttpHeaders.AUTHORIZATION));
        request.setAttribute(OPERATOR_ID_ATTRIBUTE, operatorId);
        return true;
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/admin/auth/AdminAuthService.java
@@ -80,11 +80,11 @@
    }
    @Transactional
    public void authenticate(String authorization) {
    public Long authenticate(String authorization) {
        String token = bearer(authorization);
        if (token.isBlank()) throw new BusinessException(ErrorCode.UNAUTHORIZED, "请先登录管理后台");
        List<Map<String, Object>> rows = rows("""
                select s.id session_id, o.status
                select s.id session_id, s.operator_id, o.status
                from admin_session s join admin_operator o on o.id = s.operator_id
                where s.access_token_hash = ? and s.revoked_at is null and s.access_expires_at > now()
                  and o.deleted = 0
@@ -95,6 +95,7 @@
            throw new BusinessException(ErrorCode.FORBIDDEN, "当前管理员账号已停用");
        }
        jdbc.update("update admin_session set last_used_at = now() where id = ?", number(rows.get(0).get("sessionId")));
        return number(rows.get(0).get("operatorId"));
    }
    private Map<String, Object> issueSession(Map<String, Object> operator) {
talent-card-server/src/main/java/com/talentcard/server/business/merchant/BenefitVerificationController.java
New file
@@ -0,0 +1,39 @@
package com.talentcard.server.business.merchant;
import java.util.Map;
import com.talentcard.server.business.merchant.auth.MerchantAuthService;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.common.ApiResponse;
import com.talentcard.server.request.BenefitVerificationRequest;
import com.talentcard.server.service.BenefitVerificationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "商户端-权益核销", description = "商户核销电影票、酒店权益5分钟核销码")
@RestController
@RequestMapping("/api/merchant/benefit-verifications")
public class BenefitVerificationController {
    private final BenefitVerificationService service;
    public BenefitVerificationController(BenefitVerificationService service) {
        this.service = service;
    }
    @Operation(summary = "核销权益码", description = "商户核销小程序生成的电影票/酒店5分钟核销码,并自动校验主卡共享额度、库存和核销状态")
    @PostMapping("/verify")
    public ApiResponse<Map<String, Object>> verify(
            @RequestAttribute(MerchantAuthService.PRINCIPAL_ATTRIBUTE) MerchantPrincipal principal,
            @Valid @RequestBody BenefitVerificationRequest request,
            @RequestHeader(value = "X-Idempotency-Key", required = false) String idempotencyKey
    ) {
        return ApiResponse.success(service.verify(principal, request, idempotencyKey));
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/portal/BenefitVerificationCodeController.java
New file
@@ -0,0 +1,35 @@
package com.talentcard.server.business.portal;
import java.util.Map;
import com.talentcard.server.common.ApiResponse;
import com.talentcard.server.request.BenefitVerificationCodeRequest;
import com.talentcard.server.service.BenefitVerificationCodeService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "小程序-权益核销码", description = "小程序生成电影票、酒店权益5分钟核销码")
@RestController
@RequestMapping("/api/app/benefit-verification-codes")
public class BenefitVerificationCodeController {
    private final BenefitVerificationCodeService service;
    public BenefitVerificationCodeController(BenefitVerificationCodeService service) {
        this.service = service;
    }
    @Operation(summary = "生成权益核销码", description = "根据主卡/亲属附属卡共享额度、商户剩余券或酒店库存生成5分钟有效核销码")
    @PostMapping
    public ApiResponse<Map<String, Object>> create(
            @Valid @RequestBody BenefitVerificationCodeRequest request,
            @RequestHeader(value = "X-Idempotency-Key", required = false) String idempotencyKey
    ) {
        return ApiResponse.success(service.generate(request, idempotencyKey));
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/portal/DependentCardApplyController.java
New file
@@ -0,0 +1,73 @@
package com.talentcard.server.business.portal;
import java.util.List;
import com.talentcard.server.common.ApiResponse;
import com.talentcard.server.request.DependentCardApplyRequest;
import com.talentcard.server.request.DependentCardUnbindRequest;
import com.talentcard.server.response.DependentCardApplyItemResponse;
import com.talentcard.server.service.DependentCardApplyService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "家属附属卡申请", description = "小程序家属附属卡申请、查询与解绑")
@RestController
@RequestMapping("/api/app/dependent-card-applies")
public class DependentCardApplyController {
    private final DependentCardApplyService service;
    public DependentCardApplyController(DependentCardApplyService service) {
        this.service = service;
    }
    @Operation(
            summary = "提交家属附属卡申请",
            description = "仅已开通权限的卓越人才卡可申请;每张主卡最多绑定2个家属,状态0/1/2的申请累计最多3个"
    )
    @ApiResponses({
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "申请提交成功"),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "请求参数不正确"),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "主卡不存在或已失效"),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "无申请权限、绑定名额已满或累计申请已达到3个"),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "申请保存失败")
    })
    @PostMapping
    public ApiResponse<Void> submit(
            @Valid @RequestBody DependentCardApplyRequest request
    ) {
        service.submit(request);
        return ApiResponse.success();
    }
    @Operation(summary = "查询家属附属卡申请", description = "手机号中间4位统一显示为****")
    @GetMapping
    public ApiResponse<List<DependentCardApplyItemResponse>> list(
            @RequestParam Long talentCardId
    ) {
        return ApiResponse.success(service.list(talentCardId));
    }
    @Operation(summary = "解绑家属附属卡", description = "仅已通过审核的家属附属卡可以解绑")
    @ApiResponses({
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "解绑成功"),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "申请不存在"),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "当前状态不允许解绑")
    })
    @PostMapping("/{id}/unbind")
    public ApiResponse<DependentCardApplyItemResponse> unbind(
            @PathVariable Long id,
            @Valid @RequestBody DependentCardUnbindRequest request
    ) {
        return ApiResponse.success(service.unbind(id, request));
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/portal/ServiceAppointmentApplicationController.java
New file
@@ -0,0 +1,35 @@
package com.talentcard.server.business.portal;
import java.util.Map;
import com.talentcard.server.common.ApiResponse;
import com.talentcard.server.request.ServiceAppointmentSubmitRequest;
import com.talentcard.server.service.ServiceAppointmentApplicationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "小程序-预约申请", description = "小程序提交体检、医院快捷通道、酒店、配偶就业、学生托管预约申请")
@RestController
@RequestMapping("/api/app/service-appointment-applications")
public class ServiceAppointmentApplicationController {
    private final ServiceAppointmentApplicationService service;
    public ServiceAppointmentApplicationController(ServiceAppointmentApplicationService service) {
        this.service = service;
    }
    @Operation(summary = "提交预约申请", description = "按预约类型保存预约信息;学生托管默认进入待审核状态,其他服务默认待处理")
    @PostMapping
    public ApiResponse<Map<String, Object>> submit(
            @Valid @RequestBody ServiceAppointmentSubmitRequest request,
            @RequestHeader(value = "X-Idempotency-Key", required = false) String idempotencyKey
    ) {
        return ApiResponse.success(service.submit(request, idempotencyKey));
    }
}
talent-card-server/src/main/java/com/talentcard/server/common/SensitiveDataCrypto.java
@@ -3,6 +3,7 @@
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import javax.crypto.Cipher;
@@ -16,6 +17,7 @@
public class SensitiveDataCrypto {
    private static final SecureRandom RANDOM = new SecureRandom();
    private static final String VERSION_PREFIX = "v1:";
    private final byte[] key;
    public SensitiveDataCrypto(@Value("${app.privacy.encryption-key}") String encryptionKey) {
@@ -37,9 +39,27 @@
            byte[] payload = new byte[iv.length + encrypted.length];
            System.arraycopy(iv, 0, payload, 0, iv.length);
            System.arraycopy(encrypted, 0, payload, iv.length, encrypted.length);
            return "v1:" + Base64.getUrlEncoder().withoutPadding().encodeToString(payload);
            return VERSION_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(payload);
        } catch (Exception exception) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "敏感信息加密失败");
        }
    }
}
    public String decrypt(String cipherText) {
        if (cipherText == null || cipherText.isBlank()) return "";
        if (!cipherText.startsWith(VERSION_PREFIX)) return cipherText;
        try {
            byte[] payload = Base64.getUrlDecoder().decode(cipherText.substring(VERSION_PREFIX.length()));
            if (payload.length <= 12) {
                throw new IllegalArgumentException("Invalid encrypted payload");
            }
            byte[] iv = Arrays.copyOfRange(payload, 0, 12);
            byte[] encrypted = Arrays.copyOfRange(payload, 12, payload.length);
            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
            cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, iv));
            return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8);
        } catch (Exception exception) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "敏感信息解密失败");
        }
    }
}
talent-card-server/src/main/java/com/talentcard/server/config/BenefitVerificationExceptionHandler.java
New file
@@ -0,0 +1,76 @@
package com.talentcard.server.config;
import java.util.stream.Collectors;
import com.talentcard.server.business.merchant.BenefitVerificationController;
import com.talentcard.server.business.portal.BenefitVerificationCodeController;
import com.talentcard.server.common.ApiResponse;
import com.talentcard.server.common.BusinessException;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(assignableTypes = {
        BenefitVerificationCodeController.class,
        BenefitVerificationController.class
})
public class BenefitVerificationExceptionHandler {
    private static final Logger log = LoggerFactory.getLogger(BenefitVerificationExceptionHandler.class);
    private static final int ERROR_CODE = 500;
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException exception) {
        return error(exception.getMessage());
    }
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<Void>> handleMethodArgumentNotValid(MethodArgumentNotValidException exception) {
        String message = exception.getBindingResult().getFieldErrors().stream()
                .map(error -> error.getField() + " " + error.getDefaultMessage())
                .collect(Collectors.joining("; "));
        return error(message);
    }
    @ExceptionHandler(BindException.class)
    public ResponseEntity<ApiResponse<Void>> handleBindException(BindException exception) {
        String message = exception.getBindingResult().getFieldErrors().stream()
                .map(error -> error.getField() + " " + error.getDefaultMessage())
                .collect(Collectors.joining("; "));
        return error(message);
    }
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ApiResponse<Void>> handleConstraintViolation(ConstraintViolationException exception) {
        String message = exception.getConstraintViolations().stream()
                .map(violation -> violation.getPropertyPath() + " " + violation.getMessage())
                .collect(Collectors.joining("; "));
        return error(message);
    }
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<ApiResponse<Void>> handleUnreadableMessage(HttpMessageNotReadableException exception) {
        return error("请求内容格式不正确");
    }
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleException(Exception exception) {
        log.error("Benefit verification request exception", exception);
        return error("服务器内部错误");
    }
    private ResponseEntity<ApiResponse<Void>> error(String message) {
        String safeMessage = message == null || message.isBlank() ? "服务器内部错误" : message;
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ApiResponse.failure(ERROR_CODE, safeMessage));
    }
}
talent-card-server/src/main/java/com/talentcard/server/config/ServiceAppointmentExceptionHandler.java
New file
@@ -0,0 +1,82 @@
package com.talentcard.server.config;
import java.util.stream.Collectors;
import com.talentcard.server.business.admin.ServiceAppointmentAdminController;
import com.talentcard.server.business.portal.ServiceAppointmentApplicationController;
import com.talentcard.server.common.ApiResponse;
import com.talentcard.server.common.BusinessException;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(assignableTypes = {
        ServiceAppointmentApplicationController.class,
        ServiceAppointmentAdminController.class
})
public class ServiceAppointmentExceptionHandler {
    private static final Logger log = LoggerFactory.getLogger(ServiceAppointmentExceptionHandler.class);
    private static final int ERROR_CODE = 500;
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException exception) {
        return error(exception.getMessage());
    }
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<Void>> handleMethodArgumentNotValid(MethodArgumentNotValidException exception) {
        String message = exception.getBindingResult().getFieldErrors().stream()
                .map(error -> error.getDefaultMessage() == null ? error.getField() + "参数不正确" : error.getDefaultMessage())
                .collect(Collectors.joining(";"));
        return error(message);
    }
    @ExceptionHandler(BindException.class)
    public ResponseEntity<ApiResponse<Void>> handleBindException(BindException exception) {
        String message = exception.getBindingResult().getFieldErrors().stream()
                .map(error -> error.getDefaultMessage() == null ? error.getField() + "参数不正确" : error.getDefaultMessage())
                .collect(Collectors.joining(";"));
        return error(message);
    }
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ApiResponse<Void>> handleConstraintViolation(ConstraintViolationException exception) {
        String message = exception.getConstraintViolations().stream()
                .map(violation -> violation.getMessage())
                .collect(Collectors.joining(";"));
        return error(message);
    }
    @ExceptionHandler(MissingServletRequestParameterException.class)
    public ResponseEntity<ApiResponse<Void>> handleMissingRequestParameter(MissingServletRequestParameterException exception) {
        return error("请求参数不能为空:" + exception.getParameterName());
    }
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<ApiResponse<Void>> handleUnreadableMessage(HttpMessageNotReadableException exception) {
        return error("请求内容格式不正确");
    }
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleException(Exception exception) {
        log.error("Service appointment request exception", exception);
        return error("服务器内部错误");
    }
    private ResponseEntity<ApiResponse<Void>> error(String message) {
        String safeMessage = message == null || message.isBlank() ? "服务器内部错误" : message;
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ApiResponse.failure(ERROR_CODE, safeMessage));
    }
}
talent-card-server/src/main/java/com/talentcard/server/config/SwaggerConfig.java
New file
@@ -0,0 +1,18 @@
package com.talentcard.server.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SwaggerConfig {
    @Bean
    public OpenAPI talentCardOpenApi() {
        return new OpenAPI().info(new Info()
                .title("人才卡服务接口")
                .description("人才卡后端服务 OpenAPI 文档,启动后访问 /swagger-ui/index.html 或 /swagger-ui.html")
                .version("v1"));
    }
}
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitCinemaCoupon.java
New file
@@ -0,0 +1,24 @@
package com.talentcard.server.entity;
import java.time.LocalDate;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("cinema_coupon")
public class BenefitCinemaCoupon {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long merchantId;
    private String couponName;
    private Integer stock;
    private Integer perUserLimit;
    private LocalDate validEnd;
    private String talentLevels;
    private Integer status;
    private Integer sortNo;
    private Integer deleted;
}
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitCinemaCouponClaim.java
New file
@@ -0,0 +1,31 @@
package com.talentcard.server.entity;
import java.time.LocalDate;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("cinema_coupon_claim")
public class BenefitCinemaCouponClaim {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String claimNo;
    private Long couponId;
    private Long merchantId;
    private Long talentId;
    private Long cardId;
    private String cardNo;
    private LocalDate validEnd;
    private String status;
    private String idempotencyKey;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    private Integer deleted;
    private String exchangeCode;
    private String codeStatus;
    private LocalDateTime usedAt;
}
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitFamilyCardApplication.java
New file
@@ -0,0 +1,18 @@
package com.talentcard.server.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("family_card_application")
public class BenefitFamilyCardApplication {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long talentId;
    private String status;
    private Long familyTalentId;
    private Long familyCardId;
    private Integer deleted;
}
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitFreeStayVerificationCode.java
New file
@@ -0,0 +1,19 @@
package com.talentcard.server.entity;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("free_stay_verification_code")
public class BenefitFreeStayVerificationCode {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String codeToken;
    private Long voucherId;
    private LocalDateTime expiresAt;
    private Integer status;
}
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitFreeStayVoucher.java
New file
@@ -0,0 +1,23 @@
package com.talentcard.server.entity;
import java.time.LocalDate;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("talent_free_stay_voucher")
public class BenefitFreeStayVoucher {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String voucherNo;
    private Long talentId;
    private Long merchantId;
    private Long cardId;
    private Integer totalCount;
    private Integer usedCount;
    private LocalDate validEnd;
    private Integer status;
}
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitMerchant.java
New file
@@ -0,0 +1,17 @@
package com.talentcard.server.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("merchant")
public class BenefitMerchant {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String merchantName;
    private String merchantType;
    private Integer status;
    private Integer deleted;
}
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitMerchantBenefitRule.java
New file
@@ -0,0 +1,21 @@
package com.talentcard.server.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("merchant_benefit_rule")
public class BenefitMerchantBenefitRule {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long merchantId;
    private Long productId;
    private String cardLevel;
    private Integer freeCount;
    private String resetCycle;
    private Integer canFamilyUse;
    private Integer status;
    private Integer deleted;
}
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitMerchantProduct.java
New file
@@ -0,0 +1,25 @@
package com.talentcard.server.entity;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("merchant_product")
public class BenefitMerchantProduct {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long merchantId;
    private String productNo;
    private String productType;
    private String productName;
    private Integer stock;
    private Integer salesCount;
    private LocalDateTime validStartTime;
    private LocalDateTime validEndTime;
    private Integer status;
    private Integer deleted;
}
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitMovieTicketRule.java
New file
@@ -0,0 +1,17 @@
package com.talentcard.server.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("movie_ticket_rule")
public class BenefitMovieTicketRule {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String talentLevel;
    private Integer enabled;
    private Integer claimableCount;
    private String resetCycle;
}
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitOrder.java
New file
@@ -0,0 +1,29 @@
package com.talentcard.server.entity;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("benefit_order")
public class BenefitOrder {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String orderNo;
    private Long talentId;
    private Long cardId;
    private Long benefitId;
    private Long merchantId;
    private Long storeId;
    private Long productId;
    private Integer orderStatus;
    private Integer verifyStatus;
    private Long payAmount;
    private Long subsidyAmount;
    private String productNameSnapshot;
    private LocalDateTime updatedAt;
    private Integer deleted;
}
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitOrderDynamicVerificationCode.java
New file
@@ -0,0 +1,21 @@
package com.talentcard.server.entity;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("order_dynamic_verification_code")
public class BenefitOrderDynamicVerificationCode {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String codeToken;
    private String displayCode;
    private Long orderId;
    private Long talentId;
    private LocalDateTime expiresAt;
    private Integer status;
}
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitTalentCard.java
New file
@@ -0,0 +1,25 @@
package com.talentcard.server.entity;
import java.time.LocalDate;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("talent_card")
public class BenefitTalentCard {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long talentId;
    private String cardNo;
    private String cardType;
    private String cardLevel;
    private LocalDate validStart;
    private LocalDate validEnd;
    private Integer status;
    private LocalDateTime expireTime;
    private Integer deleted;
}
talent-card-server/src/main/java/com/talentcard/server/entity/BenefitVerifyRecord.java
New file
@@ -0,0 +1,30 @@
package com.talentcard.server.entity;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("verify_record")
public class BenefitVerifyRecord {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String verifyNo;
    private Long orderId;
    private Long talentId;
    private Long cardId;
    private Long benefitId;
    private Long merchantId;
    private Long storeId;
    private Long staffId;
    private String verifyType;
    private Long verifyAmount;
    private Long subsidyAmount;
    private LocalDateTime verifyTime;
    private String remark;
    private Integer status;
    private String idempotencyKey;
}
talent-card-server/src/main/java/com/talentcard/server/entity/ChildInformation.java
New file
@@ -0,0 +1,28 @@
package com.talentcard.server.entity;
import java.time.LocalDate;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("child_information")
public class ChildInformation {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long talentId;
    private String childName;
    private String gender;
    private LocalDate birthDate;
    private String proofFileIds;
    private String auditStatus;
    private String rejectReason;
    private String idempotencyKey;
    private LocalDateTime submittedAt;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    private Integer deleted;
}
talent-card-server/src/main/java/com/talentcard/server/entity/DependentCardApply.java
New file
@@ -0,0 +1,108 @@
package com.talentcard.server.entity;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
@TableName("dependent_card_apply")
public class DependentCardApply {
    public static final int STATUS_PENDING_REVIEW = 0;
    public static final int STATUS_APPROVED = 1;
    public static final int STATUS_REJECTED = 2;
    public static final int STATUS_CANCELLED = 3;
    @TableId(type = IdType.AUTO)
    private Long id;
    private String applyNo;
    private Long talentId;
    private Long talentCardId;
    private String talentName;
    private String talentMobile;
    private String talentCardNo;
    private String cardLevel;
    @TableField(exist = false)
    private Integer isFamilyCardEnabled;
    @TableField(exist = false)
    private Integer familyCardLimit;
    private String dependentName;
    private String dependentMobile;
    private String dependentIdCardNo;
    private String relation;
    private String proofFiles;
    private String applyType;
    private LocalDateTime applyTime;
    private Integer status;
    private Long auditBy;
    private LocalDateTime auditTime;
    private String auditRemark;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    @TableLogic
    private Integer deleted;
    public DependentCardApply() {
    }
    public DependentCardApply(
            String applyNo,
            Long talentId,
            Long talentCardId,
            String talentName,
            String talentMobile,
            String talentCardNo,
            String cardLevel,
            String dependentName,
            String dependentMobile,
            String dependentIdCardNo,
            String relation
    ) {
        LocalDateTime now = LocalDateTime.now();
        this.applyNo = applyNo;
        this.talentId = talentId;
        this.talentCardId = talentCardId;
        this.talentName = talentName;
        this.talentMobile = talentMobile;
        this.talentCardNo = talentCardNo;
        this.cardLevel = cardLevel;
        this.dependentName = dependentName;
        this.dependentMobile = dependentMobile;
        this.dependentIdCardNo = dependentIdCardNo;
        this.relation = relation;
        this.applyType = "FAMILY";
        this.applyTime = now;
        this.status = STATUS_PENDING_REVIEW;
        this.createdAt = now;
        this.updatedAt = now;
        this.deleted = 0;
    }
    public Long getId() { return id; }
    public String getApplyNo() { return applyNo; }
    public Long getTalentId() { return talentId; }
    public Long getTalentCardId() { return talentCardId; }
    public String getTalentName() { return talentName; }
    public String getTalentMobile() { return talentMobile; }
    public String getTalentCardNo() { return talentCardNo; }
    public String getCardLevel() { return cardLevel; }
    public Integer getIsFamilyCardEnabled() { return isFamilyCardEnabled; }
    public Integer getFamilyCardLimit() { return familyCardLimit; }
    public String getDependentName() { return dependentName; }
    public String getDependentMobile() { return dependentMobile; }
    public String getDependentIdCardNo() { return dependentIdCardNo; }
    public String getRelation() { return relation; }
    public String getProofFiles() { return proofFiles; }
    public String getApplyType() { return applyType; }
    public LocalDateTime getApplyTime() { return applyTime; }
    public Integer getStatus() { return status; }
    public Long getAuditBy() { return auditBy; }
    public LocalDateTime getAuditTime() { return auditTime; }
    public String getAuditRemark() { return auditRemark; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
    public Integer getDeleted() { return deleted; }
}
talent-card-server/src/main/java/com/talentcard/server/entity/DependentCardApplyStatusLog.java
New file
@@ -0,0 +1,54 @@
package com.talentcard.server.entity;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
@TableName("dependent_card_apply_status_log")
public class DependentCardApplyStatusLog {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long applyId;
    private String applyNo;
    private Integer fromStatus;
    private Integer toStatus;
    private String operatorType;
    private Long operatorId;
    private String remark;
    private LocalDateTime createdAt;
    public DependentCardApplyStatusLog() {
    }
    public DependentCardApplyStatusLog(
            Long applyId,
            String applyNo,
            Integer fromStatus,
            Integer toStatus,
            String operatorType,
            Long operatorId,
            String remark
    ) {
        this.applyId = applyId;
        this.applyNo = applyNo;
        this.fromStatus = fromStatus;
        this.toStatus = toStatus;
        this.operatorType = operatorType;
        this.operatorId = operatorId;
        this.remark = remark;
        this.createdAt = LocalDateTime.now();
    }
    public Long getId() { return id; }
    public Long getApplyId() { return applyId; }
    public String getApplyNo() { return applyNo; }
    public Integer getFromStatus() { return fromStatus; }
    public Integer getToStatus() { return toStatus; }
    public String getOperatorType() { return operatorType; }
    public Long getOperatorId() { return operatorId; }
    public String getRemark() { return remark; }
    public LocalDateTime getCreatedAt() { return createdAt; }
}
talent-card-server/src/main/java/com/talentcard/server/entity/ServiceApplication.java
New file
@@ -0,0 +1,70 @@
package com.talentcard.server.entity;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("service_application")
public class ServiceApplication {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String applicationNo;
    private String applicationType;
    private Long talentId;
    private Long cardId;
    private Long merchantId;
    private String talentName;
    private String talentMobile;
    private String talentLevel;
    private String cardNo;
    private String merchantName;
    private String hospitalName;
    private String contactMobile;
    private String packageName;
    private String productName;
    private String applicantName;
    private String applicantMobile;
    private String applicantIdCardNo;
    private String expectedTime;
    private LocalDate checkInDate;
    private LocalDate checkOutDate;
    private LocalDateTime submittedAt;
    private String submissionType;
    private String plateNo;
    private String parkingLot;
    private String alipayMobile;
    private String spouseName;
    private String spouseMobile;
    private String spouseIdCardNo;
    private String spousePoliticalStatus;
    private String spouseEducation;
    private String spouseSchoolMajor;
    private String spouseWorkUnit;
    private String spouseInInstitution;
    private String spouseWorkExperience;
    private Long childId;
    private String childName;
    private String childGender;
    private LocalDate childBirthDate;
    private String proofFileIds;
    private Integer guestCount;
    private String subsidy;
    private String status;
    private LocalDateTime handledAt;
    private String handlerName;
    private String rejectReason;
    private String institutionAddressSnapshot;
    private BigDecimal institutionLongitudeSnapshot;
    private BigDecimal institutionLatitudeSnapshot;
    private String estimatedProcessingTime;
    private String idempotencyKey;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    private Integer deleted;
}
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitCinemaCouponClaimMapper.java
New file
@@ -0,0 +1,9 @@
package com.talentcard.server.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.talentcard.server.entity.BenefitCinemaCouponClaim;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BenefitCinemaCouponClaimMapper extends BaseMapper<BenefitCinemaCouponClaim> {
}
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitCinemaCouponMapper.java
New file
@@ -0,0 +1,9 @@
package com.talentcard.server.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.talentcard.server.entity.BenefitCinemaCoupon;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BenefitCinemaCouponMapper extends BaseMapper<BenefitCinemaCoupon> {
}
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitFamilyCardApplicationMapper.java
New file
@@ -0,0 +1,9 @@
package com.talentcard.server.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.talentcard.server.entity.BenefitFamilyCardApplication;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BenefitFamilyCardApplicationMapper extends BaseMapper<BenefitFamilyCardApplication> {
}
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitFreeStayVerificationCodeMapper.java
New file
@@ -0,0 +1,9 @@
package com.talentcard.server.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.talentcard.server.entity.BenefitFreeStayVerificationCode;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BenefitFreeStayVerificationCodeMapper extends BaseMapper<BenefitFreeStayVerificationCode> {
}
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitFreeStayVoucherMapper.java
New file
@@ -0,0 +1,9 @@
package com.talentcard.server.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.talentcard.server.entity.BenefitFreeStayVoucher;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BenefitFreeStayVoucherMapper extends BaseMapper<BenefitFreeStayVoucher> {
}
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitMerchantBenefitRuleMapper.java
New file
@@ -0,0 +1,9 @@
package com.talentcard.server.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.talentcard.server.entity.BenefitMerchantBenefitRule;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BenefitMerchantBenefitRuleMapper extends BaseMapper<BenefitMerchantBenefitRule> {
}
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitMerchantMapper.java
New file
@@ -0,0 +1,9 @@
package com.talentcard.server.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.talentcard.server.entity.BenefitMerchant;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BenefitMerchantMapper extends BaseMapper<BenefitMerchant> {
}
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitMerchantProductMapper.java
New file
@@ -0,0 +1,9 @@
package com.talentcard.server.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.talentcard.server.entity.BenefitMerchantProduct;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BenefitMerchantProductMapper extends BaseMapper<BenefitMerchantProduct> {
}
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitMovieTicketRuleMapper.java
New file
@@ -0,0 +1,9 @@
package com.talentcard.server.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.talentcard.server.entity.BenefitMovieTicketRule;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BenefitMovieTicketRuleMapper extends BaseMapper<BenefitMovieTicketRule> {
}
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitOrderDynamicVerificationCodeMapper.java
New file
@@ -0,0 +1,9 @@
package com.talentcard.server.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.talentcard.server.entity.BenefitOrderDynamicVerificationCode;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BenefitOrderDynamicVerificationCodeMapper extends BaseMapper<BenefitOrderDynamicVerificationCode> {
}
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitOrderMapper.java
New file
@@ -0,0 +1,9 @@
package com.talentcard.server.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.talentcard.server.entity.BenefitOrder;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BenefitOrderMapper extends BaseMapper<BenefitOrder> {
}
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitTalentCardMapper.java
New file
@@ -0,0 +1,9 @@
package com.talentcard.server.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.talentcard.server.entity.BenefitTalentCard;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BenefitTalentCardMapper extends BaseMapper<BenefitTalentCard> {
}
talent-card-server/src/main/java/com/talentcard/server/mapper/BenefitVerifyRecordMapper.java
New file
@@ -0,0 +1,9 @@
package com.talentcard.server.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.talentcard.server.entity.BenefitVerifyRecord;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BenefitVerifyRecordMapper extends BaseMapper<BenefitVerifyRecord> {
}
talent-card-server/src/main/java/com/talentcard/server/mapper/ChildInformationMapper.java
New file
@@ -0,0 +1,9 @@
package com.talentcard.server.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.talentcard.server.entity.ChildInformation;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ChildInformationMapper extends BaseMapper<ChildInformation> {
}
talent-card-server/src/main/java/com/talentcard/server/mapper/DependentCardApplyMapper.java
New file
@@ -0,0 +1,35 @@
package com.talentcard.server.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.talentcard.server.entity.DependentCardApply;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface DependentCardApplyMapper extends BaseMapper<DependentCardApply> {
    @Select("""
            select c.id as talentCardId, c.talent_id as talentId, c.card_no as talentCardNo,
                   c.card_level as cardLevel, c.is_family_card_enabled as isFamilyCardEnabled,
                   c.family_card_limit as familyCardLimit, u.name as talentName, u.mobile as talentMobile
            from talent_card c
            join talent_user u on u.id = c.talent_id and u.deleted = 0
            where c.id = #{talentCardId}
              and c.deleted = 0
              and c.card_type = 'PERSONAL'
              and c.status = 1
            limit 1
            for update
            """)
    DependentCardApply selectOwnerCard(@Param("talentCardId") Long talentCardId);
    @Select("""
            select *
            from dependent_card_apply
            where id = #{id} and deleted = 0
            limit 1
            for update
            """)
    DependentCardApply selectByIdForUpdate(@Param("id") Long id);
}
talent-card-server/src/main/java/com/talentcard/server/mapper/DependentCardApplyStatusLogMapper.java
New file
@@ -0,0 +1,9 @@
package com.talentcard.server.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.talentcard.server.entity.DependentCardApplyStatusLog;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DependentCardApplyStatusLogMapper extends BaseMapper<DependentCardApplyStatusLog> {
}
talent-card-server/src/main/java/com/talentcard/server/mapper/ServiceApplicationMapper.java
New file
@@ -0,0 +1,120 @@
package com.talentcard.server.mapper;
import java.util.Map;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.talentcard.server.entity.ServiceApplication;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ServiceApplicationMapper extends BaseMapper<ServiceApplication> {
    @Select("""
            select c.id as cardId,
                   c.talent_id as talentId,
                   c.card_no as cardNo,
                   c.card_level as talentLevel,
                   c.card_type as cardType,
                   u.name as talentName,
                   u.mobile as talentMobile,
                   u.id_card_no as talentIdCardNo
            from talent_card c
            join talent_user u on u.id = c.talent_id and u.deleted = 0 and u.status = 1
            where c.deleted = 0
              and c.status = 1
              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.expire_time is null or c.expire_time > now())
              and ((#{cardId} is not null and c.id = #{cardId}) or (#{cardId} is null and c.talent_id = #{talentId}))
              and (#{talentId} is null or c.talent_id = #{talentId})
            order by case when c.card_type = 'PERSONAL' then 0 else 1 end, c.id desc
            limit 1
            """)
    Map<String, Object> selectActiveCardSnapshot(@Param("talentId") Long talentId, @Param("cardId") Long cardId);
    @Select("""
            select id as merchantId,
                   merchant_name as merchantName,
                   merchant_type as merchantType,
                   contact_mobile as contactMobile,
                   address,
                   longitude,
                   latitude
            from merchant
            where id = #{merchantId} and deleted = 0 and status = 1
            limit 1
            """)
    Map<String, Object> selectMerchantSnapshot(@Param("merchantId") Long merchantId);
    @Select("""
            select m.id as merchantId,
                   m.merchant_name as merchantName,
                   m.merchant_type as merchantType,
                   m.contact_mobile as contactMobile,
                   m.address,
                   m.longitude,
                   m.latitude
            from merchant m
            join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0
            where m.id = #{merchantId}
              and mapping.category_key = #{categoryKey}
              and m.deleted = 0
              and m.status = 1
            limit 1
            """)
    Map<String, Object> selectPremiumMerchantSnapshot(@Param("merchantId") Long merchantId, @Param("categoryKey") String categoryKey);
    @Select("""
            select p.id as packageId,
                   p.merchant_id as merchantId,
                   p.package_name as packageName,
                   p.stock,
                   p.status,
                   p.unavailable_reason as unavailableReason
            from medical_package p
            where p.id = #{packageId}
              and (#{merchantId} is null or p.merchant_id = #{merchantId})
              and p.deleted = 0
            limit 1
            """)
    Map<String, Object> selectMedicalPackage(@Param("packageId") Long packageId, @Param("merchantId") Long merchantId);
    @Select("""
            select id as productId,
                   merchant_id as merchantId,
                   product_name as productName,
                   stock,
                   status
            from merchant_product
            where id = #{productId}
              and (#{merchantId} is null or merchant_id = #{merchantId})
              and deleted = 0
            limit 1
            """)
    Map<String, Object> selectMerchantProduct(@Param("productId") Long productId, @Param("merchantId") Long merchantId);
    @Select("""
            select id as schoolId,
                   school_name as schoolName,
                   address,
                   latitude,
                   longitude,
                   contact_mobile as contactMobile,
                   enabled
            from children_entrustment_school
            where id = #{schoolId} and deleted = 0
            limit 1
            """)
    Map<String, Object> selectChildrenSchool(@Param("schoolId") Long schoolId);
    @Select("""
            select *
            from service_application
            where id = #{id} and deleted = 0
            limit 1
            for update
            """)
    ServiceApplication selectByIdForUpdate(@Param("id") Long id);
}
talent-card-server/src/main/java/com/talentcard/server/request/BenefitVerificationCodeRequest.java
New file
@@ -0,0 +1,29 @@
package com.talentcard.server.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
@Schema(name = "生成权益核销码请求", description = "生成电影票/酒店权益5分钟核销码请求")
public record BenefitVerificationCodeRequest(
        @Schema(description = "权益类型:MOVIE_TICKET电影票、HOTEL酒店", example = "MOVIE_TICKET", requiredMode = Schema.RequiredMode.REQUIRED)
        @NotBlank(message = "权益类型不能为空")
        @Size(max = 32, message = "权益类型长度不能超过32")
        String benefitType,
        @Schema(description = "人才用户ID;cardId未传时必填,系统自动选择有效人才卡", example = "2")
        @Positive(message = "人才用户ID必须大于0")
        Long talentId,
        @Schema(description = "人才卡ID;支持主卡或已审核通过的亲属附属卡", example = "2")
        @Positive(message = "人才卡ID必须大于0")
        Long cardId,
        @Schema(description = "商户ID", example = "22", requiredMode = Schema.RequiredMode.REQUIRED)
        @NotNull(message = "商户ID不能为空")
        @Positive(message = "商户ID必须大于0")
        Long merchantId
) {
}
talent-card-server/src/main/java/com/talentcard/server/request/BenefitVerificationRequest.java
New file
@@ -0,0 +1,23 @@
package com.talentcard.server.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
@Schema(name = "权益核销请求", description = "电影票/酒店权益核销请求")
public record BenefitVerificationRequest(
        @Schema(description = "权益类型:MOVIE_TICKET电影票、HOTEL酒店", example = "MOVIE_TICKET", requiredMode = Schema.RequiredMode.REQUIRED)
        @NotBlank
        @Size(max = 32)
        String benefitType,
        @Schema(description = "核销码;由小程序生成码接口返回,5分钟内有效", example = "MV2A1B2C3D4E5F6", requiredMode = Schema.RequiredMode.REQUIRED)
        @NotBlank
        @Size(max = 128)
        String verifyCode,
        @Schema(description = "核销备注", example = "商户确认用户到店使用")
        @Size(max = 512)
        String remark
) {
}
talent-card-server/src/main/java/com/talentcard/server/request/DependentCardApplyRequest.java
New file
@@ -0,0 +1,36 @@
package com.talentcard.server.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
@Schema(name = "家属附属卡申请提交参数", description = "家属附属卡申请提交参数")
public record DependentCardApplyRequest(
        @Schema(description = "主卡ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED)
        @NotNull(message = "主卡ID不能为空")
        Long talentCardId,
        @Schema(description = "家属姓名", example = "张三", maxLength = 64, requiredMode = Schema.RequiredMode.REQUIRED)
        @NotBlank(message = "家属姓名不能为空")
        @Size(max = 64, message = "家属姓名不能超过64个字符")
        String dependentName,
        @Schema(description = "家属身份证号", example = "510105199001011234", maxLength = 32, requiredMode = Schema.RequiredMode.REQUIRED)
        @NotBlank(message = "家属证件号不能为空")
        @Size(max = 32, message = "家属证件号不能超过32个字符")
        @Pattern(regexp = "^(?:[0-9]{15}|[0-9]{17}[0-9Xx])$", message = "家属证件号格式不正确")
        String dependentIdCardNo,
        @Schema(description = "家属手机号", example = "13800138000", pattern = "^1[3-9][0-9]{9}$", requiredMode = Schema.RequiredMode.REQUIRED)
        @NotBlank(message = "家属手机号不能为空")
        @Pattern(regexp = "^1[3-9][0-9]{9}$", message = "家属手机号格式不正确")
        String dependentMobile,
        @Schema(description = "与主卡持有人的关系", example = "配偶", maxLength = 32, requiredMode = Schema.RequiredMode.REQUIRED)
        @NotBlank(message = "家属关系不能为空")
        @Size(max = 32, message = "家属关系不能超过32个字符")
        String relation
) {
}
talent-card-server/src/main/java/com/talentcard/server/request/DependentCardAuditRequest.java
New file
@@ -0,0 +1,16 @@
package com.talentcard.server.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
@Schema(name = "家属附属卡审核参数", description = "家属附属卡审核参数")
public record DependentCardAuditRequest(
        @NotNull
        @Schema(description = "审核结果:1通过、2驳回", example = "1", allowableValues = {"1", "2"})
        Integer status,
        @Size(max = 500)
        @Schema(description = "审核备注;驳回时必填", example = "证明材料不完整")
        String auditRemark
) {
}
talent-card-server/src/main/java/com/talentcard/server/request/DependentCardUnbindRequest.java
New file
@@ -0,0 +1,14 @@
package com.talentcard.server.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
@Schema(name = "家属附属卡解绑参数", description = "家属附属卡解绑参数")
public record DependentCardUnbindRequest(
        @NotNull
        @Positive
        @Schema(description = "主卡ID", example = "1")
        Long talentCardId
) {
}
talent-card-server/src/main/java/com/talentcard/server/request/ServiceAppointmentStatusUpdateRequest.java
New file
@@ -0,0 +1,26 @@
package com.talentcard.server.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
@Schema(name = "预约申请处理请求", description = "后台处理预约申请状态")
public record ServiceAppointmentStatusUpdateRequest(
        @Schema(description = "处理状态:待处理、处理中、已处理、已驳回、已取消、待审核、审核通过、审核不通过;也支持通过/拒绝别名", example = "已处理", requiredMode = Schema.RequiredMode.REQUIRED)
        @NotBlank(message = "处理状态不能为空")
        @Size(max = 32, message = "处理状态长度不能超过32")
        String status,
        @Schema(description = "处理人名称;未传默认管理员", example = "管理员")
        @Size(max = 64, message = "处理人名称长度不能超过64")
        String handlerName,
        @Schema(description = "处理/审核备注;拒绝或驳回时必填", example = "资料不完整,请补充后重新提交")
        @Size(max = 500, message = "处理备注长度不能超过500")
        String remark,
        @Schema(description = "驳回原因;兼容字段,未传remark时使用", example = "资料不完整")
        @Size(max = 500, message = "驳回原因长度不能超过500")
        String rejectReason
) {
}
talent-card-server/src/main/java/com/talentcard/server/request/ServiceAppointmentSubmitRequest.java
New file
@@ -0,0 +1,156 @@
package com.talentcard.server.request;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
@Schema(name = "预约申请提交请求", description = "小程序提交体检、医院快捷通道、酒店、配偶就业、学生托管预约申请")
public record ServiceAppointmentSubmitRequest(
        @Schema(description = "预约类型:MEDICAL_RESERVATION体检、FAST_TRACK快捷通道、HOTEL_RESERVATION酒店、SPOUSE_EMPLOYMENT配偶就业、CHILD_ENTRUSTMENT学生托管", example = "MEDICAL_RESERVATION", requiredMode = Schema.RequiredMode.REQUIRED)
        @NotBlank(message = "预约类型不能为空")
        @Size(max = 64, message = "预约类型长度不能超过64")
        String applicationType,
        @Schema(description = "人才用户ID;cardId未传时必填", example = "2")
        @Positive(message = "人才用户ID必须大于0")
        Long talentId,
        @Schema(description = "人才卡ID;传入后按该卡保存预约快照", example = "8")
        @Positive(message = "人才卡ID必须大于0")
        Long cardId,
        @Schema(description = "服务商户/医院/酒店ID;体检、酒店、配偶就业建议传入", example = "12")
        @Positive(message = "商户ID必须大于0")
        Long merchantId,
        @Schema(description = "体检套餐ID;体检预约可传,不传则使用packageName", example = "1")
        @Positive(message = "体检套餐ID必须大于0")
        Long packageId,
        @Schema(description = "酒店房型/套餐ID;酒店预约可传,不传则使用productName", example = "20")
        @Positive(message = "酒店房型/套餐ID必须大于0")
        Long productId,
        @Schema(description = "学生托管学校ID;可选", example = "3")
        @Positive(message = "学校ID必须大于0")
        Long schoolId,
        @Schema(description = "已存在的子女信息ID;学生托管可传,未传则按子女字段新建审核资料", example = "5")
        @Positive(message = "子女信息ID必须大于0")
        Long childId,
        @Schema(description = "申请人姓名;体检/快捷通道可传,默认使用持卡人姓名", example = "张某某")
        @Size(max = 64, message = "申请人姓名长度不能超过64")
        String applicantName,
        @Schema(description = "申请人手机号;体检/快捷通道可传,默认使用持卡人手机号", example = "13800001688")
        @Size(max = 20, message = "申请人手机号长度不能超过20")
        String applicantMobile,
        @Schema(description = "申请人证件号;体检/快捷通道可传,默认使用持卡人证件号", example = "510402199001011688")
        @Size(max = 64, message = "申请人证件号长度不能超过64")
        String applicantIdCardNo,
        @Schema(description = "医院名称;未传merchantId时必填", example = "攀枝花市中心医院")
        @Size(max = 128, message = "医院名称长度不能超过128")
        String hospitalName,
        @Schema(description = "商户/酒店/服务机构名称;未传merchantId时可传", example = "全季酒店")
        @Size(max = 128, message = "服务机构名称长度不能超过128")
        String merchantName,
        @Schema(description = "联系电话;未传merchantId时可传", example = "0812-3509201")
        @Size(max = 20, message = "联系电话长度不能超过20")
        String contactMobile,
        @Schema(description = "预约套餐/体检套餐名称", example = "A套餐")
        @Size(max = 128, message = "套餐名称长度不能超过128")
        String packageName,
        @Schema(description = "预约项目/房型/服务内容", example = "商务大床房")
        @Size(max = 128, message = "预约项目长度不能超过128")
        String productName,
        @Schema(description = "期望预约时间;如2026-05-28或2026-05-22 至 2026-05-23", example = "2026-05-28")
        @Size(max = 128, message = "期望预约时间长度不能超过128")
        String expectedTime,
        @Schema(description = "期望预约日期;格式yyyy-MM-dd,未传expectedTime时使用", example = "2026-05-28")
        @Size(max = 32, message = "期望预约日期长度不能超过32")
        String expectedDate,
        @Schema(description = "入住日期;酒店预约使用,格式yyyy-MM-dd", example = "2026-05-22")
        @Size(max = 32, message = "入住日期长度不能超过32")
        String checkInDate,
        @Schema(description = "离店日期;酒店预约使用,格式yyyy-MM-dd", example = "2026-05-23")
        @Size(max = 32, message = "离店日期长度不能超过32")
        String checkOutDate,
        @Schema(description = "入住人数;酒店预约可传", example = "2")
        @Positive(message = "入住人数必须大于0")
        Integer guestCount,
        @Schema(description = "是否使用补贴/权益;酒店预约可传", example = "是")
        @Size(max = 16, message = "补贴标识长度不能超过16")
        String subsidy,
        @Schema(description = "配偶姓名;配偶就业必填", example = "李四")
        @Size(max = 64, message = "配偶姓名长度不能超过64")
        String spouseName,
        @Schema(description = "配偶手机号;配偶就业必填", example = "13600006666")
        @Size(max = 20, message = "配偶手机号长度不能超过20")
        String spouseMobile,
        @Schema(description = "配偶证件号;配偶就业必填", example = "510402199003033456")
        @Size(max = 64, message = "配偶证件号长度不能超过64")
        String spouseIdCardNo,
        @Schema(description = "政治面貌", example = "群众")
        @Size(max = 64, message = "政治面貌长度不能超过64")
        String spousePoliticalStatus,
        @Schema(description = "学历", example = "本科")
        @Size(max = 64, message = "学历长度不能超过64")
        String spouseEducation,
        @Schema(description = "毕业院校及专业", example = "四川大学计算机专业")
        @Size(max = 255, message = "毕业院校及专业长度不能超过255")
        String spouseSchoolMajor,
        @Schema(description = "现工作单位及职务", example = "待就业")
        @Size(max = 255, message = "现工作单位及职务长度不能超过255")
        String spouseWorkUnit,
        @Schema(description = "是否为机关事业单位在编人员", example = "否")
        @Size(max = 16, message = "是否在编长度不能超过16")
        String spouseInInstitution,
        @Schema(description = "工作经历", example = "具有软件项目实施、客户服务相关经验。")
        @Size(max = 4000, message = "工作经历长度不能超过4000")
        String spouseWorkExperience,
        @Schema(description = "子女姓名;学生托管必填", example = "陈娇娇")
        @Size(max = 64, message = "子女姓名长度不能超过64")
        String childName,
        @Schema(description = "子女性别;学生托管必填:男、女", example = "男")
        @Size(max = 8, message = "子女性别长度不能超过8")
        String childGender,
        @Schema(description = "子女出生年月/出生日期;格式yyyy-MM-dd", example = "2020-05-23")
        @Size(max = 32, message = "出生日期长度不能超过32")
        String childBirthDate,
        @Schema(description = "亲属关系证明材料文件ID列表;学生托管最多3个", example = "[\"file-1\",\"file-2\"]")
        List<String> proofFileIds,
        @Schema(description = "提交备注", example = "请尽快联系")
        @Size(max = 500, message = "备注长度不能超过500")
        String remark
) {
}
talent-card-server/src/main/java/com/talentcard/server/response/DependentCardApplyAdminResponse.java
New file
@@ -0,0 +1,50 @@
package com.talentcard.server.response;
import java.time.LocalDateTime;
import com.talentcard.server.entity.DependentCardApply;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "DependentCardApplyAdminResponse", description = "管理后台家属附属卡申请信息")
public record DependentCardApplyAdminResponse(
        Long id,
        String applyNo,
        Long talentId,
        Long talentCardId,
        String talentName,
        String talentMobile,
        String talentCardNo,
        String cardLevel,
        String dependentName,
        String dependentMobile,
        String dependentIdCardNo,
        String relation,
        Integer status,
        LocalDateTime applyTime,
        Long auditBy,
        LocalDateTime auditTime,
        String auditRemark
) {
    public static DependentCardApplyAdminResponse from(DependentCardApply apply) {
        return new DependentCardApplyAdminResponse(
                apply.getId(),
                apply.getApplyNo(),
                apply.getTalentId(),
                apply.getTalentCardId(),
                apply.getTalentName(),
                apply.getTalentMobile(),
                apply.getTalentCardNo(),
                apply.getCardLevel(),
                apply.getDependentName(),
                apply.getDependentMobile(),
                apply.getDependentIdCardNo(),
                apply.getRelation(),
                apply.getStatus(),
                apply.getApplyTime(),
                apply.getAuditBy(),
                apply.getAuditTime(),
                apply.getAuditRemark()
        );
    }
}
talent-card-server/src/main/java/com/talentcard/server/response/DependentCardApplyItemResponse.java
New file
@@ -0,0 +1,52 @@
package com.talentcard.server.response;
import com.talentcard.server.entity.DependentCardApply;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "DependentCardApplyItemResponse", description = "小程序家属附属卡申请信息")
public record DependentCardApplyItemResponse(
        @Schema(description = "数据ID", example = "1001")
        Long id,
        @Schema(description = "亲属姓名", example = "张三")
        String dependentName,
        @Schema(description = "脱敏手机号", example = "138****8000")
        String dependentMobile,
        @Schema(description = "审核状态:0待审核、1已通过、2已驳回、3已解绑", example = "1")
        Integer status,
        @Schema(description = "审核状态文本", example = "已通过")
        String statusText,
        @Schema(description = "亲属关系", example = "配偶")
        String relation
) {
    public static DependentCardApplyItemResponse from(DependentCardApply apply) {
        return new DependentCardApplyItemResponse(
                apply.getId(),
                apply.getDependentName(),
                maskMobile(apply.getDependentMobile()),
                apply.getStatus(),
                statusText(apply.getStatus()),
                apply.getRelation()
        );
    }
    private static String maskMobile(String mobile) {
        if (mobile == null || mobile.length() < 7) {
            return "";
        }
        return mobile.substring(0, 3) + "****" + mobile.substring(7);
    }
    private static String statusText(Integer status) {
        if (status == null) {
            return "";
        }
        return switch (status) {
            case DependentCardApply.STATUS_PENDING_REVIEW -> "待审核";
            case DependentCardApply.STATUS_APPROVED -> "已通过";
            case DependentCardApply.STATUS_REJECTED -> "已驳回";
            case DependentCardApply.STATUS_CANCELLED -> "已解绑";
            default -> "未知";
        };
    }
}
talent-card-server/src/main/java/com/talentcard/server/response/DependentCardApplyResponse.java
New file
@@ -0,0 +1,46 @@
package com.talentcard.server.response;
import java.time.LocalDateTime;
import com.talentcard.server.entity.DependentCardApply;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "DependentCardApplyResponse", description = "家属附属卡申请提交结果")
public record DependentCardApplyResponse(
        @Schema(description = "申请记录ID", example = "1001")
        Long id,
        @Schema(description = "申请编号", example = "DCA202608031100001234")
        String applyNo,
        @Schema(description = "主卡持有人ID", example = "2")
        Long talentId,
        @Schema(description = "主卡ID", example = "1")
        Long talentCardId,
        @Schema(description = "家属姓名", example = "张三")
        String dependentName,
        @Schema(description = "家属身份证号", example = "510105199001011234")
        String dependentIdCardNo,
        @Schema(description = "家属手机号", example = "13800138000")
        String dependentMobile,
        @Schema(description = "家属关系", example = "配偶")
        String relation,
        @Schema(description = "审核状态:0待审核、1通过、2驳回、3取消", example = "0", allowableValues = {"0", "1", "2", "3"})
        Integer status,
        @Schema(description = "申请时间", example = "2026-08-03T11:00:00")
        LocalDateTime applyTime
) {
    public static DependentCardApplyResponse from(DependentCardApply apply) {
        return new DependentCardApplyResponse(
                apply.getId(),
                apply.getApplyNo(),
                apply.getTalentId(),
                apply.getTalentCardId(),
                apply.getDependentName(),
                apply.getDependentIdCardNo(),
                apply.getDependentMobile(),
                apply.getRelation(),
                apply.getStatus(),
                apply.getApplyTime()
        );
    }
}
talent-card-server/src/main/java/com/talentcard/server/response/DependentCardApplyStatusLogResponse.java
New file
@@ -0,0 +1,34 @@
package com.talentcard.server.response;
import java.time.LocalDateTime;
import com.talentcard.server.entity.DependentCardApplyStatusLog;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "DependentCardApplyStatusLogResponse", description = "家属附属卡申请状态变更记录")
public record DependentCardApplyStatusLogResponse(
        Long id,
        Long applyId,
        String applyNo,
        Integer fromStatus,
        Integer toStatus,
        String operatorType,
        Long operatorId,
        String remark,
        LocalDateTime createdAt
) {
    public static DependentCardApplyStatusLogResponse from(DependentCardApplyStatusLog log) {
        return new DependentCardApplyStatusLogResponse(
                log.getId(),
                log.getApplyId(),
                log.getApplyNo(),
                log.getFromStatus(),
                log.getToStatus(),
                log.getOperatorType(),
                log.getOperatorId(),
                log.getRemark(),
                log.getCreatedAt()
        );
    }
}
talent-card-server/src/main/java/com/talentcard/server/service/BenefitQuotaService.java
New file
@@ -0,0 +1,459 @@
package com.talentcard.server.service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.TemporalAdjusters;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import com.talentcard.server.entity.BenefitCinemaCouponClaim;
import com.talentcard.server.entity.BenefitFamilyCardApplication;
import com.talentcard.server.entity.BenefitFreeStayVoucher;
import com.talentcard.server.entity.BenefitMerchantBenefitRule;
import com.talentcard.server.entity.BenefitMovieTicketRule;
import com.talentcard.server.entity.BenefitOrder;
import com.talentcard.server.entity.BenefitTalentCard;
import com.talentcard.server.entity.BenefitVerifyRecord;
import com.talentcard.server.mapper.BenefitCinemaCouponClaimMapper;
import com.talentcard.server.mapper.BenefitFamilyCardApplicationMapper;
import com.talentcard.server.mapper.BenefitFreeStayVoucherMapper;
import com.talentcard.server.mapper.BenefitMerchantBenefitRuleMapper;
import com.talentcard.server.mapper.BenefitMovieTicketRuleMapper;
import com.talentcard.server.mapper.BenefitOrderMapper;
import com.talentcard.server.mapper.BenefitTalentCardMapper;
import com.talentcard.server.mapper.BenefitVerifyRecordMapper;
import org.springframework.stereotype.Service;
@Service
public class BenefitQuotaService {
    public static final String MOVIE_TICKET = "MOVIE_TICKET";
    public static final String HOTEL = "HOTEL";
    private static final int DEFAULT_HOTEL_TOTAL = 5;
    private static final List<String> HOTEL_VERIFY_TYPES = List.of(
            "HOTEL", "HOTEL_FREE", "FREE_STAY", "HOTEL_ORDER", "HOTEL_RESERVATION"
    );
    private final BenefitTalentCardMapper cardMapper;
    private final BenefitFamilyCardApplicationMapper familyMapper;
    private final BenefitMovieTicketRuleMapper movieRuleMapper;
    private final BenefitCinemaCouponClaimMapper claimMapper;
    private final BenefitFreeStayVoucherMapper voucherMapper;
    private final BenefitMerchantBenefitRuleMapper merchantBenefitRuleMapper;
    private final BenefitVerifyRecordMapper verifyRecordMapper;
    private final BenefitOrderMapper orderMapper;
    public BenefitQuotaService(
            BenefitTalentCardMapper cardMapper,
            BenefitFamilyCardApplicationMapper familyMapper,
            BenefitMovieTicketRuleMapper movieRuleMapper,
            BenefitCinemaCouponClaimMapper claimMapper,
            BenefitFreeStayVoucherMapper voucherMapper,
            BenefitMerchantBenefitRuleMapper merchantBenefitRuleMapper,
            BenefitVerifyRecordMapper verifyRecordMapper,
            BenefitOrderMapper orderMapper
    ) {
        this.cardMapper = cardMapper;
        this.familyMapper = familyMapper;
        this.movieRuleMapper = movieRuleMapper;
        this.claimMapper = claimMapper;
        this.voucherMapper = voucherMapper;
        this.merchantBenefitRuleMapper = merchantBenefitRuleMapper;
        this.verifyRecordMapper = verifyRecordMapper;
        this.orderMapper = orderMapper;
    }
    public QuotaSnapshot requireAvailable(String rawBenefitType, Long cardId, Long talentId) {
        return requireAvailable(rawBenefitType, cardId, talentId, null, null, 1);
    }
    public QuotaSnapshot requireAvailable(String rawBenefitType, Long cardId, Long talentId,
                                          Long merchantId, Long productId, int quantity) {
        if (quantity < 1) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "核销数量必须大于0");
        }
        QuotaSnapshot snapshot = current(rawBenefitType, cardId, talentId, merchantId, productId);
        if (snapshot.remaining() < quantity) {
            throw new BusinessException(ErrorCode.CONFLICT, "主卡权益剩余次数不足");
        }
        return snapshot;
    }
    public QuotaSnapshot current(String rawBenefitType, Long cardId, Long talentId) {
        return current(rawBenefitType, cardId, talentId, null, null);
    }
    public QuotaSnapshot current(String rawBenefitType, Long cardId, Long talentId, Long merchantId, Long productId) {
        String benefitType = normalizeBenefitType(rawBenefitType);
        PrimaryCard primary = lockPrimaryCard(cardId, talentId);
        QuotaPolicy policy = policy(benefitType, primary, merchantId, productId);
        int used = usedQuota(benefitType, primary.sharedCardIds(), policy.windowStart());
        return new QuotaSnapshot(
                benefitType,
                primary.sourceTalentId(),
                primary.sourceCardId(),
                primary.sourceCardNo(),
                primary.sourceCardType(),
                primary.primaryTalentId(),
                primary.primaryCardId(),
                primary.primaryCardNo(),
                primary.cardLevel(),
                policy.total(),
                used,
                Math.max(policy.total() - used, 0),
                policy.resetCycle(),
                policy.windowStart(),
                primary.familyCard(),
                primary.sharedCardIds()
        );
    }
    public String normalizeBenefitType(String value) {
        String type = value == null ? "" : value.trim().toUpperCase(Locale.ROOT).replace('-', '_');
        return switch (type) {
            case "MOVIE", "MOVIE_TICKET", "CINEMA", "CINEMA_TICKET" -> MOVIE_TICKET;
            case "HOTEL", "FREE_STAY", "HOTEL_RESERVATION", "STAY" -> HOTEL;
            default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "权益类型仅支持MOVIE_TICKET电影票或HOTEL酒店");
        };
    }
    public List<String> cardLevelAliases(String cardLevel) {
        Set<String> aliases = new LinkedHashSet<>();
        String raw = cardLevel == null ? "" : cardLevel.trim();
        if (!raw.isBlank()) {
            aliases.add(raw);
        }
        String upper = raw.toUpperCase(Locale.ROOT);
        if (upper.contains("EXCELLENT") || raw.contains("\u5353\u8d8a")) {
            aliases.add("EXCELLENT");
            aliases.add("\u5353\u8d8a\u4eba\u624d\u5361");
            aliases.add("\u5353\u8d8a");
        }
        if (upper.contains("OUTSTANDING") || raw.contains("\u6770\u51fa")) {
            aliases.add("OUTSTANDING");
            aliases.add("\u6770\u51fa\u4eba\u624d\u5361");
            aliases.add("\u6770\u51fa");
        }
        if (upper.contains("ELITE") || raw.contains("\u83c1\u82f1")) {
            aliases.add("ELITE");
            aliases.add("\u83c1\u82f1\u4eba\u624d\u5361");
            aliases.add("\u83c1\u82f1");
        }
        return aliases.isEmpty() ? List.of(raw) : List.copyOf(aliases);
    }
    private PrimaryCard lockPrimaryCard(Long cardId, Long talentId) {
        if (cardId == null && talentId == null) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "人才卡ID或人才用户ID不能为空");
        }
        BenefitTalentCard sourceCard = cardId == null ? activeCardForTalent(talentId) : card(cardId);
        Long sourceCardId = sourceCard.getId();
        Long sourceTalentId = sourceCard.getTalentId();
        String sourceCardType = text(sourceCard.getCardType()).toUpperCase(Locale.ROOT);
        if ("PERSONAL".equals(sourceCardType) || sourceCardType.isBlank()) {
            return primaryCard(sourceCardId, sourceTalentId, sourceCard.getCardNo(), sourceCardType, sourceCardId, sourceTalentId);
        }
        BenefitFamilyCardApplication owner = familyMapper.selectOne(new QueryWrapper<BenefitFamilyCardApplication>()
                .eq("deleted", 0)
                .eq("family_card_id", sourceCardId)
                .isNotNull("talent_id")
                .orderByDesc("id")
                .last("limit 1"));
        if (owner == null) {
            throw new BusinessException(ErrorCode.CONFLICT, "亲属附属卡未找到绑定的主卡");
        }
        BenefitTalentCard primary = activePersonalCard(owner.getTalentId());
        return primaryCard(sourceCardId, sourceTalentId, sourceCard.getCardNo(), sourceCardType,
                primary.getId(), primary.getTalentId());
    }
    private PrimaryCard primaryCard(Long sourceCardId, Long sourceTalentId, String sourceCardNo, String sourceCardType,
                                    Long primaryCardId, Long primaryTalentId) {
        BenefitTalentCard primary = cardMapper.selectOne(new QueryWrapper<BenefitTalentCard>()
                .eq("id", primaryCardId)
                .eq("talent_id", primaryTalentId)
                .eq("deleted", 0)
                .last("limit 1 for update"));
        if (primary == null) {
            throw new BusinessException(ErrorCode.CONFLICT, "主卡不存在");
        }
        if (intValue(primary.getStatus()) != 1 || notStarted(primary.getValidStart())
                || expiredDate(primary.getValidEnd()) || expiredDateTime(primary.getExpireTime())) {
            throw new BusinessException(ErrorCode.CONFLICT, "主卡当前不可用");
        }
        return new PrimaryCard(
                sourceTalentId,
                sourceCardId,
                sourceCardNo,
                sourceCardType,
                primaryTalentId,
                primaryCardId,
                primary.getCardNo(),
                text(primary.getCardLevel()),
                !"PERSONAL".equals(sourceCardType),
                sharedCardIds(primaryCardId, primaryTalentId)
        );
    }
    private List<Long> sharedCardIds(Long primaryCardId, Long primaryTalentId) {
        Set<Long> ids = new LinkedHashSet<>();
        ids.add(primaryCardId);
        List<BenefitFamilyCardApplication> applications = familyMapper.selectList(new QueryWrapper<BenefitFamilyCardApplication>()
                .eq("deleted", 0)
                .eq("talent_id", primaryTalentId)
                .isNotNull("family_card_id"));
        for (BenefitFamilyCardApplication application : applications) {
            if (application.getFamilyCardId() != null) {
                ids.add(application.getFamilyCardId());
            }
        }
        if (ids.size() == 1) {
            return List.copyOf(ids);
        }
        List<BenefitTalentCard> activeCards = cardMapper.selectList(new QueryWrapper<BenefitTalentCard>()
                .select("id")
                .eq("deleted", 0)
                .eq("status", 1)
                .in("id", ids));
        Set<Long> activeIds = new LinkedHashSet<>();
        activeIds.add(primaryCardId);
        for (BenefitTalentCard card : activeCards) {
            activeIds.add(card.getId());
        }
        return List.copyOf(activeIds);
    }
    private BenefitTalentCard card(Long cardId) {
        BenefitTalentCard card = cardMapper.selectOne(new QueryWrapper<BenefitTalentCard>()
                .eq("id", cardId)
                .eq("deleted", 0)
                .last("limit 1"));
        if (card == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "人才卡不存在");
        }
        if (intValue(card.getStatus()) != 1 || notStarted(card.getValidStart())
                || expiredDate(card.getValidEnd()) || expiredDateTime(card.getExpireTime())) {
            throw new BusinessException(ErrorCode.CONFLICT, "人才卡当前不可用");
        }
        return card;
    }
    private BenefitTalentCard activeCardForTalent(Long talentId) {
        BenefitTalentCard card = cardMapper.selectOne(new QueryWrapper<BenefitTalentCard>()
                .eq("talent_id", talentId)
                .eq("deleted", 0)
                .eq("status", 1)
                .and(wrapper -> wrapper.isNull("valid_start").or().le("valid_start", LocalDate.now()))
                .and(wrapper -> wrapper.isNull("valid_end").or().ge("valid_end", LocalDate.now()))
                .and(wrapper -> wrapper.isNull("expire_time").or().gt("expire_time", LocalDateTime.now()))
                .last("order by case when card_type = 'PERSONAL' then 0 else 1 end, id desc limit 1"));
        if (card == null) {
            throw new BusinessException(ErrorCode.CONFLICT, "未找到有效人才卡");
        }
        return card;
    }
    private BenefitTalentCard activePersonalCard(Long talentId) {
        BenefitTalentCard card = cardMapper.selectOne(new QueryWrapper<BenefitTalentCard>()
                .eq("talent_id", talentId)
                .eq("card_type", "PERSONAL")
                .eq("deleted", 0)
                .eq("status", 1)
                .and(wrapper -> wrapper.isNull("valid_start").or().le("valid_start", LocalDate.now()))
                .and(wrapper -> wrapper.isNull("valid_end").or().ge("valid_end", LocalDate.now()))
                .and(wrapper -> wrapper.isNull("expire_time").or().gt("expire_time", LocalDateTime.now()))
                .orderByDesc("id")
                .last("limit 1"));
        if (card == null) {
            throw new BusinessException(ErrorCode.CONFLICT, "未找到有效主卡");
        }
        return card;
    }
    private QuotaPolicy policy(String benefitType, PrimaryCard primary, Long merchantId, Long productId) {
        if (MOVIE_TICKET.equals(benefitType)) {
            return moviePolicy(primary);
        }
        return hotelPolicy(primary, merchantId, productId);
    }
    private QuotaPolicy moviePolicy(PrimaryCard primary) {
        List<String> aliases = cardLevelAliases(primary.cardLevel());
        BenefitMovieTicketRule rule = movieRuleMapper.selectOne(new QueryWrapper<BenefitMovieTicketRule>()
                .in("talent_level", aliases)
                .last("limit 1"));
        if (rule == null || intValue(rule.getEnabled()) != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "当前卡类型未启用电影票权益");
        }
        int total = intValue(rule.getClaimableCount());
        if (total < 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "当前卡类型未配置电影票权益次数");
        }
        String resetCycle = text(rule.getResetCycle());
        return new QuotaPolicy(total, resetCycle, windowStart(resetCycle, true));
    }
    private QuotaPolicy hotelPolicy(PrimaryCard primary, Long merchantId, Long productId) {
        if (merchantId != null) {
            List<String> aliases = cardLevelAliases(primary.cardLevel());
            QueryWrapper<BenefitMerchantBenefitRule> query = new QueryWrapper<BenefitMerchantBenefitRule>()
                    .eq("deleted", 0)
                    .eq("status", 1)
                    .eq("merchant_id", merchantId)
                    .in("card_level", aliases)
                    .isNotNull("free_count")
                    .gt("free_count", 0);
            if (productId == null) {
                query.isNull("product_id").orderByDesc("id").last("limit 1");
            } else {
                query.and(wrapper -> wrapper.eq("product_id", productId).or().isNull("product_id"))
                        .last("order by case when product_id = " + productId + " then 0 else 1 end, id desc limit 1");
            }
            BenefitMerchantBenefitRule rule = merchantBenefitRuleMapper.selectOne(query);
            if (rule != null) {
                if (primary.familyCard() && intValue(rule.getCanFamilyUse()) != 1) {
                    throw new BusinessException(ErrorCode.CONFLICT, "亲属附属卡不可使用该酒店权益");
                }
                String resetCycle = text(rule.getResetCycle());
                return new QuotaPolicy(intValue(rule.getFreeCount()), resetCycle, windowStart(resetCycle, false));
            }
        }
        QueryWrapper<BenefitFreeStayVoucher> voucherQuery = new QueryWrapper<BenefitFreeStayVoucher>()
                .select("total_count")
                .eq("talent_id", primary.primaryTalentId())
                .eq("status", 1)
                .ge("valid_end", LocalDate.now())
                .orderByDesc("total_count")
                .last("limit 1");
        if (merchantId != null) {
            voucherQuery.eq("merchant_id", merchantId);
        }
        BenefitFreeStayVoucher voucher = voucherMapper.selectOne(voucherQuery);
        int configured = voucher == null || intValue(voucher.getTotalCount()) < 1 ? DEFAULT_HOTEL_TOTAL : voucher.getTotalCount();
        return new QuotaPolicy(configured, "YEARLY", windowStart("YEARLY", false));
    }
    private int usedQuota(String benefitType, List<Long> sharedCardIds, LocalDateTime windowStart) {
        if (MOVIE_TICKET.equals(benefitType)) {
            return usedMovieQuota(sharedCardIds, windowStart);
        }
        return usedHotelQuota(sharedCardIds, windowStart);
    }
    private int usedMovieQuota(List<Long> sharedCardIds, LocalDateTime windowStart) {
        Long used = claimMapper.selectCount(new QueryWrapper<BenefitCinemaCouponClaim>()
                .eq("deleted", 0)
                .in("card_id", sharedCardIds)
                .apply("((upper(coalesce(code_status, 'ACTIVE')) = 'USED' and used_at >= {0}) or "
                        + "(upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE' and updated_at >= {1} "
                        + "and updated_at > date_sub(now(), interval 300 second)))", windowStart, windowStart));
        return intValue(used);
    }
    private int usedHotelQuota(List<Long> sharedCardIds, LocalDateTime windowStart) {
        Long records = verifyRecordMapper.selectCount(new QueryWrapper<BenefitVerifyRecord>()
                .in("card_id", sharedCardIds)
                .eq("status", 1)
                .ge("verify_time", windowStart)
                .and(wrapper -> wrapper.in("verify_type", HOTEL_VERIFY_TYPES)
                        .or()
                        .apply("exists (select 1 from benefit_order o "
                                + "join merchant m on m.id = o.merchant_id and m.deleted = 0 and m.merchant_type = 'hotel' "
                                + "where o.id = verify_record.order_id and o.deleted = 0)")));
        Long ordersWithoutRecord = orderMapper.selectCount(new QueryWrapper<BenefitOrder>()
                .eq("deleted", 0)
                .in("card_id", sharedCardIds)
                .eq("verify_status", 1)
                .ge("updated_at", windowStart)
                .apply("exists (select 1 from merchant m where m.id = benefit_order.merchant_id "
                        + "and m.deleted = 0 and m.merchant_type = 'hotel')")
                .apply("not exists (select 1 from verify_record r where r.order_id = benefit_order.id and r.status = 1)"));
        return intValue(records) + intValue(ordersWithoutRecord);
    }
    private LocalDateTime windowStart(String resetCycle, boolean monthlyDefault) {
        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("DAILY") || cycle.contains("\u5929") || cycle.contains("\u65e5")) {
            return today.atStartOfDay();
        }
        if (cycle.contains("WEEK") || cycle.contains("\u5468") || cycle.contains("\u661f\u671f")) {
            return today.minusDays(today.getDayOfWeek().getValue() - 1L).atStartOfDay();
        }
        if (cycle.contains("YEAR") || cycle.contains("ANNUAL") || cycle.contains("\u5e74")) {
            return today.with(TemporalAdjusters.firstDayOfYear()).atStartOfDay();
        }
        if (cycle.contains("MONTH") || cycle.contains("\u6708") || monthlyDefault) {
            return today.withDayOfMonth(1).atStartOfDay();
        }
        return today.with(TemporalAdjusters.firstDayOfYear()).atStartOfDay();
    }
    private boolean notStarted(LocalDate value) {
        return value != null && value.isAfter(LocalDate.now());
    }
    private boolean expiredDate(LocalDate value) {
        return value != null && value.isBefore(LocalDate.now());
    }
    private boolean expiredDateTime(LocalDateTime value) {
        return value != null && !value.isAfter(LocalDateTime.now());
    }
    private String text(Object value) {
        return value == null ? "" : String.valueOf(value).trim();
    }
    private int intValue(Object value) {
        return value instanceof Number number ? number.intValue() : 0;
    }
    private record PrimaryCard(
            Long sourceTalentId,
            Long sourceCardId,
            String sourceCardNo,
            String sourceCardType,
            Long primaryTalentId,
            Long primaryCardId,
            String primaryCardNo,
            String cardLevel,
            boolean familyCard,
            List<Long> sharedCardIds
    ) {
    }
    private record QuotaPolicy(int total, String resetCycle, LocalDateTime windowStart) {
    }
    public record QuotaSnapshot(
            String benefitType,
            Long talentId,
            Long cardId,
            String cardNo,
            String cardType,
            Long primaryTalentId,
            Long primaryCardId,
            String primaryCardNo,
            String cardLevel,
            int total,
            int used,
            int remaining,
            String resetCycle,
            LocalDateTime windowStart,
            boolean familyCard,
            List<Long> sharedCardIds
    ) {
    }
}
talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationCodeService.java
New file
@@ -0,0 +1,457 @@
package com.talentcard.server.service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.request.BenefitVerificationCodeRequest;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import com.talentcard.server.entity.BenefitCinemaCoupon;
import com.talentcard.server.entity.BenefitCinemaCouponClaim;
import com.talentcard.server.entity.BenefitFreeStayVerificationCode;
import com.talentcard.server.entity.BenefitFreeStayVoucher;
import com.talentcard.server.entity.BenefitMerchant;
import com.talentcard.server.mapper.BenefitCinemaCouponClaimMapper;
import com.talentcard.server.mapper.BenefitCinemaCouponMapper;
import com.talentcard.server.mapper.BenefitFreeStayVerificationCodeMapper;
import com.talentcard.server.mapper.BenefitFreeStayVoucherMapper;
import com.talentcard.server.mapper.BenefitMerchantMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class BenefitVerificationCodeService {
    public static final int CODE_SECONDS = 300;
    public static final String MOVIE_TOKEN_PREFIX = "MV2";
    public static final String HOTEL_TOKEN_PREFIX = "HV2";
    private final BenefitQuotaService quotaService;
    private final BenefitMerchantMapper merchantMapper;
    private final BenefitCinemaCouponMapper couponMapper;
    private final BenefitCinemaCouponClaimMapper claimMapper;
    private final BenefitFreeStayVoucherMapper voucherMapper;
    private final BenefitFreeStayVerificationCodeMapper freeStayCodeMapper;
    public BenefitVerificationCodeService(
            BenefitQuotaService quotaService,
            BenefitMerchantMapper merchantMapper,
            BenefitCinemaCouponMapper couponMapper,
            BenefitCinemaCouponClaimMapper claimMapper,
            BenefitFreeStayVoucherMapper voucherMapper,
            BenefitFreeStayVerificationCodeMapper freeStayCodeMapper
    ) {
        this.quotaService = quotaService;
        this.merchantMapper = merchantMapper;
        this.couponMapper = couponMapper;
        this.claimMapper = claimMapper;
        this.voucherMapper = voucherMapper;
        this.freeStayCodeMapper = freeStayCodeMapper;
    }
    @Transactional
    public Map<String, Object> generate(BenefitVerificationCodeRequest request, String idempotencyKey) {
        String benefitType = quotaService.normalizeBenefitType(request.benefitType());
        if (BenefitQuotaService.MOVIE_TICKET.equals(benefitType)) {
            return generateMovieCode(request, idempotencyKey);
        }
        return generateHotelFreeStayCode(request);
    }
    private Map<String, Object> generateMovieCode(BenefitVerificationCodeRequest request, String idempotencyKey) {
        Long merchantId = requirePositive(request.merchantId(), "商户ID不能为空");
        expireExpiredMovieCodes(merchantId);
        BenefitQuotaService.QuotaSnapshot quota = quotaService.requireAvailable(
                BenefitQuotaService.MOVIE_TICKET, request.cardId(), request.talentId(), merchantId, null, 1);
        MovieCouponChoice choice = movieCouponForUpdate(merchantId, quota.cardLevel(), quota.sharedCardIds());
        BenefitCinemaCoupon coupon = choice.coupon();
        Long couponId = coupon.getId();
        int dailyLimit = choice.dailyLimit();
        int dailyUsed = choice.dailyUsed();
        String token = MOVIE_TOKEN_PREFIX + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT);
        String claimKey = claimIdempotencyKey(idempotencyKey, token);
        BenefitCinemaCouponClaim idempotent = movieClaimByIdempotencyKey(claimKey);
        if (idempotent != null) {
            ensureSameMovieClaim(idempotent, couponId, merchantId, quota.cardId());
            if (activeMovieCode(idempotent) && startsWithMoviePrefix(idempotent.getExchangeCode())) {
                return movieCodeResponse(idempotent, coupon, quota, dailyLimit, dailyUsed, false);
            }
            throw new BusinessException(ErrorCode.CONFLICT, "幂等键对应的电影票核销码已过期或已使用");
        }
        BenefitCinemaCouponClaim existing = movieClaimForUpdate(couponId, quota.talentId());
        Long claimId;
        if (existing != null) {
            String status = upper(existing.getCodeStatus());
            if ("USED".equals(status)) {
                throw new BusinessException(ErrorCode.CONFLICT, "电影票已核销");
            }
            if (activeMovieCode(existing) && startsWithMoviePrefix(existing.getExchangeCode())) {
                return movieCodeResponse(existing, coupon, quota, dailyLimit, dailyUsed, false);
            }
            boolean stockAlreadyReserved = "ACTIVE".equals(status) && !startsWithMoviePrefix(existing.getExchangeCode());
            if (!stockAlreadyReserved) {
                decrementMovieStock(couponId, merchantId);
            }
            BenefitCinemaCouponClaim update = new BenefitCinemaCouponClaim();
            update.setId(existing.getId());
            update.setExchangeCode(token);
            update.setCodeStatus("ACTIVE");
            update.setStatus("RECEIVED");
            update.setUsedAt(null);
            update.setValidEnd(coupon.getValidEnd());
            update.setIdempotencyKey(claimKey);
            claimMapper.updateById(update);
            claimId = existing.getId();
        } else {
            decrementMovieStock(couponId, merchantId);
            BenefitCinemaCouponClaim claim = new BenefitCinemaCouponClaim();
            claim.setClaimNo(BusinessIdGenerator.next("MC"));
            claim.setCouponId(couponId);
            claim.setMerchantId(merchantId);
            claim.setTalentId(quota.talentId());
            claim.setCardId(quota.cardId());
            claim.setCardNo(quota.cardNo());
            claim.setValidEnd(coupon.getValidEnd());
            claim.setStatus("RECEIVED");
            claim.setExchangeCode(token);
            claim.setCodeStatus("ACTIVE");
            claim.setIdempotencyKey(claimKey);
            claim.setDeleted(0);
            claimMapper.insert(claim);
            claimId = claim.getId();
        }
        BenefitCinemaCouponClaim claim = movieClaimById(claimId);
        return movieCodeResponse(claim, couponMapper.selectById(couponId), quota, dailyLimit, dailyUsed + 1, true);
    }
    private Map<String, Object> generateHotelFreeStayCode(BenefitVerificationCodeRequest request) {
        Long merchantId = requirePositive(request.merchantId(), "商户ID不能为空");
        ensureHotelMerchant(merchantId);
        BenefitQuotaService.QuotaSnapshot quota = quotaService.requireAvailable(
                BenefitQuotaService.HOTEL, request.cardId(), request.talentId(), merchantId, null, 1);
        BenefitFreeStayVoucher voucher = voucherForUpdate(quota.primaryTalentId(), merchantId, quota.primaryCardId(), quota.total());
        if (intValue(voucher.getStatus()) != 1 || expiredDate(voucher.getValidEnd())) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店权益券当前不可用");
        }
        if (intValue(voucher.getUsedCount()) >= Math.min(intValue(voucher.getTotalCount()), quota.total())) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店权益次数已用完");
        }
        freeStayCodeMapper.update(null, new UpdateWrapper<BenefitFreeStayVerificationCode>()
                .set("status", 2)
                .eq("voucher_id", voucher.getId())
                .eq("status", 0));
        BenefitFreeStayVerificationCode code = new BenefitFreeStayVerificationCode();
        code.setCodeToken(hotelFreeStayToken(quota.cardId()));
        code.setVoucherId(voucher.getId());
        code.setExpiresAt(LocalDateTime.now().plusSeconds(CODE_SECONDS));
        code.setStatus(0);
        freeStayCodeMapper.insert(code);
        return hotelCodeResponse(code, voucher, quota);
    }
    private MovieCouponChoice movieCouponForUpdate(Long merchantId, String cardLevel, List<Long> sharedCardIds) {
        ensureCinemaMerchant(merchantId);
        List<BenefitCinemaCoupon> coupons = couponMapper.selectList(new QueryWrapper<BenefitCinemaCoupon>()
                .eq("merchant_id", merchantId)
                .eq("deleted", 0)
                .eq("status", 1)
                .gt("stock", 0)
                .and(wrapper -> wrapper.isNull("valid_end").or().ge("valid_end", LocalDate.now()))
                .orderByAsc("sort_no", "id")
                .last("for update"));
        if (coupons.isEmpty()) {
            throw new BusinessException(ErrorCode.CONFLICT, "未找到可用电影票权益券");
        }
        List<String> aliases = quotaService.cardLevelAliases(cardLevel);
        boolean cardLevelSupported = false;
        for (BenefitCinemaCoupon coupon : coupons) {
            if (!supportsCard(coupon.getTalentLevels(), aliases)) {
                continue;
            }
            cardLevelSupported = true;
            int dailyLimit = Math.max(1, intValue(coupon.getPerUserLimit()));
            int dailyUsed = dailyMovieClaims(sharedCardIds, coupon.getId());
            if (dailyUsed < dailyLimit) {
                return new MovieCouponChoice(coupon, dailyLimit, dailyUsed);
            }
        }
        if (!cardLevelSupported) {
            throw new BusinessException(ErrorCode.CONFLICT, "当前卡类型不适用该电影票权益券");
        }
        throw new BusinessException(ErrorCode.CONFLICT, "当天电影票领取次数已达上限");
    }
    private void decrementMovieStock(Long couponId, Long merchantId) {
        int updated = couponMapper.update(null, new UpdateWrapper<BenefitCinemaCoupon>()
                .setSql("stock = stock - 1")
                .eq("id", couponId)
                .eq("merchant_id", merchantId)
                .eq("deleted", 0)
                .eq("status", 1)
                .gt("stock", 0));
        if (updated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票权益券库存不足");
        }
    }
    private int dailyMovieClaims(List<Long> sharedCardIds, Long couponId) {
        Long count = claimMapper.selectCount(new QueryWrapper<BenefitCinemaCouponClaim>()
                .eq("deleted", 0)
                .in("card_id", sharedCardIds)
                .eq("coupon_id", couponId)
                .apply("((upper(coalesce(code_status, 'ACTIVE')) = 'USED' and used_at >= current_date) or "
                        + "(upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE' and updated_at >= current_date "
                        + "and updated_at > date_sub(now(), interval 300 second)))"));
        return intValue(count);
    }
    private void expireExpiredMovieCodes(Long merchantId) {
        List<BenefitCinemaCouponClaim> expired = claimMapper.selectList(new QueryWrapper<BenefitCinemaCouponClaim>()
                .eq("deleted", 0)
                .eq("merchant_id", merchantId)
                .apply("upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE'")
                .likeRight("exchange_code", MOVIE_TOKEN_PREFIX)
                .isNull("used_at")
                .apply("updated_at <= date_sub(now(), interval 300 second)")
                .last("for update"));
        for (BenefitCinemaCouponClaim claim : expired) {
            int updated = claimMapper.update(null, new UpdateWrapper<BenefitCinemaCouponClaim>()
                    .set("code_status", "EXPIRED")
                    .set("status", "EXPIRED")
                    .eq("id", claim.getId())
                    .eq("deleted", 0)
                    .apply("upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE'"));
            if (updated == 1) {
                couponMapper.update(null, new UpdateWrapper<BenefitCinemaCoupon>()
                        .setSql("stock = stock + 1")
                        .eq("id", claim.getCouponId())
                        .eq("deleted", 0));
            }
        }
    }
    private BenefitCinemaCouponClaim movieClaimByIdempotencyKey(String key) {
        return claimMapper.selectOne(new QueryWrapper<BenefitCinemaCouponClaim>()
                .eq("deleted", 0)
                .eq("idempotency_key", key)
                .last("limit 1 for update"));
    }
    private BenefitCinemaCouponClaim movieClaimForUpdate(Long couponId, Long talentId) {
        return claimMapper.selectOne(new QueryWrapper<BenefitCinemaCouponClaim>()
                .eq("deleted", 0)
                .eq("coupon_id", couponId)
                .eq("talent_id", talentId)
                .last("limit 1 for update"));
    }
    private BenefitCinemaCouponClaim movieClaimById(Long claimId) {
        BenefitCinemaCouponClaim claim = claimMapper.selectOne(new QueryWrapper<BenefitCinemaCouponClaim>()
                .eq("deleted", 0)
                .eq("id", claimId)
                .last("limit 1"));
        if (claim == null) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "电影票领取记录保存失败");
        }
        return claim;
    }
    private void ensureSameMovieClaim(BenefitCinemaCouponClaim claim, Long couponId, Long merchantId, Long cardId) {
        if (!couponId.equals(claim.getCouponId()) || !merchantId.equals(claim.getMerchantId()) || !cardId.equals(claim.getCardId())) {
            throw new BusinessException(ErrorCode.CONFLICT, "幂等键已被其他电影票核销码请求使用");
        }
    }
    private void ensureCinemaMerchant(Long merchantId) {
        BenefitMerchant merchant = merchantMapper.selectById(merchantId);
        if (merchant == null || intValue(merchant.getDeleted()) != 0 || intValue(merchant.getStatus()) != 1
                || !"cinema".equalsIgnoreCase(String.valueOf(merchant.getMerchantType()))) {
            throw new BusinessException(ErrorCode.CONFLICT, "未找到有效影院商户");
        }
    }
    private void ensureHotelMerchant(Long merchantId) {
        BenefitMerchant merchant = merchantMapper.selectById(merchantId);
        if (merchant == null || intValue(merchant.getDeleted()) != 0 || intValue(merchant.getStatus()) != 1
                || !"hotel".equalsIgnoreCase(String.valueOf(merchant.getMerchantType()))) {
            throw new BusinessException(ErrorCode.CONFLICT, "未找到有效酒店商户");
        }
    }
    private BenefitFreeStayVoucher voucherForUpdate(Long primaryTalentId, Long merchantId, Long primaryCardId, int totalCount) {
        BenefitFreeStayVoucher voucher = voucherMapper.selectOne(new QueryWrapper<BenefitFreeStayVoucher>()
                .eq("talent_id", primaryTalentId)
                .eq("merchant_id", merchantId)
                .last("limit 1 for update"));
        LocalDate validEnd = LocalDate.now().plusYears(1);
        if (voucher == null) {
            voucher = new BenefitFreeStayVoucher();
            voucher.setVoucherNo(BusinessIdGenerator.next("FSV"));
            voucher.setTalentId(primaryTalentId);
            voucher.setMerchantId(merchantId);
            voucher.setCardId(primaryCardId);
            voucher.setTotalCount(totalCount);
            voucher.setUsedCount(0);
            voucher.setValidEnd(validEnd);
            voucher.setStatus(1);
            voucherMapper.insert(voucher);
            return voucher;
        }
        if (intValue(voucher.getStatus()) != 1 || expiredDate(voucher.getValidEnd())) {
            voucher.setTotalCount(totalCount);
            voucher.setUsedCount(0);
            voucher.setValidEnd(validEnd);
            voucher.setStatus(1);
            voucherMapper.updateById(voucher);
        } else if (intValue(voucher.getTotalCount()) < totalCount) {
            voucher.setTotalCount(totalCount);
            voucherMapper.updateById(voucher);
        }
        return voucher;
    }
    private Map<String, Object> movieCodeResponse(BenefitCinemaCouponClaim claim, BenefitCinemaCoupon coupon,
                                                  BenefitQuotaService.QuotaSnapshot quota,
                                                  int dailyLimit, int dailyUsed, boolean newlyReserved) {
        LocalDateTime updatedAt = claim.getUpdatedAt() == null ? LocalDateTime.now() : claim.getUpdatedAt();
        LocalDateTime expiresAt = updatedAt.plusSeconds(CODE_SECONDS);
        BenefitMerchant merchant = merchantMapper.selectById(claim.getMerchantId());
        Map<String, Object> result = baseResponse(BenefitQuotaService.MOVIE_TICKET, claim.getExchangeCode(), expiresAt, quota);
        result.put("displayCode", claim.getExchangeCode());
        result.put("sourceType", "CINEMA_COUPON_CLAIM");
        result.put("sourceId", claim.getId());
        result.put("merchantId", claim.getMerchantId());
        result.put("merchantName", merchant == null ? "" : merchant.getMerchantName());
        result.put("couponId", claim.getCouponId());
        result.put("couponName", coupon == null ? "" : coupon.getCouponName());
        result.put("stockRemaining", coupon == null ? null : coupon.getStock());
        result.put("dailyLimit", dailyLimit);
        result.put("dailyUsed", dailyUsed);
        result.put("dailyRemaining", Math.max(dailyLimit - dailyUsed, 0));
        result.put("quotaEffect", newlyReserved ? "RESERVED_ON_CODE_GENERATION" : "EXISTING_ACTIVE_CODE");
        return result;
    }
    private Map<String, Object> hotelCodeResponse(BenefitFreeStayVerificationCode code, BenefitFreeStayVoucher voucher,
                                                  BenefitQuotaService.QuotaSnapshot quota) {
        Map<String, Object> result = baseResponse(BenefitQuotaService.HOTEL, code.getCodeToken(), code.getExpiresAt(), quota);
        result.put("displayCode", shortDisplayCode(code.getCodeToken()));
        result.put("sourceType", "FREE_STAY_VERIFICATION_CODE");
        result.put("sourceId", code.getId());
        result.put("merchantId", voucher.getMerchantId());
        result.put("voucherId", voucher.getId());
        result.put("voucherNo", voucher.getVoucherNo());
        result.put("voucherTotal", voucher.getTotalCount());
        result.put("voucherUsed", voucher.getUsedCount());
        result.put("quotaEffect", "DEDUCT_ON_VERIFICATION");
        return result;
    }
    private Map<String, Object> baseResponse(String benefitType, Object codeToken, LocalDateTime expiresAt,
                                             BenefitQuotaService.QuotaSnapshot quota) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("benefitType", benefitType);
        result.put("verifyCode", codeToken);
        result.put("expiresAt", expiresAt);
        result.put("expiresAtEpochMillis", expiresAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
        result.put("expiresInSeconds", Math.max(0, java.time.Duration.between(LocalDateTime.now(), expiresAt).toSeconds()));
        result.put("refreshAfterSeconds", CODE_SECONDS);
        result.put("sharedQuotaMode", "AUTO_DEDUCT");
        result.put("sourceCardId", quota.cardId());
        result.put("sourceTalentId", quota.talentId());
        result.put("sourceCardType", quota.cardType());
        result.put("familyCard", quota.familyCard());
        result.put("primaryCardId", quota.primaryCardId());
        result.put("primaryTalentId", quota.primaryTalentId());
        result.put("primaryCardNo", quota.primaryCardNo());
        result.put("cardLevel", quota.cardLevel());
        result.put("quotaTotal", quota.total());
        result.put("quotaUsed", quota.used());
        result.put("quotaRemainingBefore", quota.remaining());
        result.put("quotaResetCycle", quota.resetCycle());
        result.put("quotaWindowStart", quota.windowStart());
        return result;
    }
    private String hotelFreeStayToken(Long sourceCardId) {
        long card = sourceCardId == null ? 0 : sourceCardId;
        return HOTEL_TOKEN_PREFIX + "C" + card + "P0_" + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT);
    }
    private boolean activeMovieCode(BenefitCinemaCouponClaim claim) {
        return "ACTIVE".equals(upper(claim.getCodeStatus())) && !movieCodeExpired(claim.getUpdatedAt());
    }
    private boolean movieCodeExpired(LocalDateTime updatedAt) {
        return updatedAt != null && !updatedAt.plusSeconds(CODE_SECONDS).isAfter(LocalDateTime.now());
    }
    private boolean startsWithMoviePrefix(String code) {
        return code != null && code.startsWith(MOVIE_TOKEN_PREFIX);
    }
    private String claimIdempotencyKey(String raw, String token) {
        if (raw == null || raw.isBlank()) {
            return "VCODE-" + token;
        }
        String key = raw.trim();
        if (key.length() > 100) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "幂等键长度不能超过限制");
        }
        return "VCODE-" + key;
    }
    private boolean supportsCard(String levels, List<String> aliases) {
        String configured = levels == null ? "" : levels.trim();
        if (configured.isBlank()) {
            return true;
        }
        for (String alias : aliases) {
            if (alias != null && !alias.isBlank() && configured.contains(alias)) {
                return true;
            }
        }
        return false;
    }
    private String shortDisplayCode(String token) {
        if (token == null || token.length() <= 8) {
            return token;
        }
        return token.substring(token.length() - 8);
    }
    private Long requirePositive(Long value, String message) {
        if (value == null || value <= 0) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, message);
        }
        return value;
    }
    private boolean expiredDate(LocalDate value) {
        return value != null && value.isBefore(LocalDate.now());
    }
    private String upper(String value) {
        return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
    }
    private int intValue(Object value) {
        return value instanceof Number number ? number.intValue() : 0;
    }
    private record MovieCouponChoice(BenefitCinemaCoupon coupon, int dailyLimit, int dailyUsed) {
    }
}
talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationService.java
New file
@@ -0,0 +1,508 @@
package com.talentcard.server.service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.request.BenefitVerificationRequest;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import com.talentcard.server.entity.BenefitCinemaCoupon;
import com.talentcard.server.entity.BenefitCinemaCouponClaim;
import com.talentcard.server.entity.BenefitFreeStayVerificationCode;
import com.talentcard.server.entity.BenefitFreeStayVoucher;
import com.talentcard.server.entity.BenefitMerchant;
import com.talentcard.server.entity.BenefitMerchantProduct;
import com.talentcard.server.entity.BenefitOrder;
import com.talentcard.server.entity.BenefitOrderDynamicVerificationCode;
import com.talentcard.server.entity.BenefitVerifyRecord;
import com.talentcard.server.mapper.BenefitCinemaCouponClaimMapper;
import com.talentcard.server.mapper.BenefitCinemaCouponMapper;
import com.talentcard.server.mapper.BenefitFreeStayVerificationCodeMapper;
import com.talentcard.server.mapper.BenefitFreeStayVoucherMapper;
import com.talentcard.server.mapper.BenefitMerchantMapper;
import com.talentcard.server.mapper.BenefitMerchantProductMapper;
import com.talentcard.server.mapper.BenefitOrderDynamicVerificationCodeMapper;
import com.talentcard.server.mapper.BenefitOrderMapper;
import com.talentcard.server.mapper.BenefitVerifyRecordMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class BenefitVerificationService {
    private final BenefitQuotaService quotaService;
    private final BenefitVerifyRecordMapper verifyRecordMapper;
    private final BenefitCinemaCouponClaimMapper claimMapper;
    private final BenefitCinemaCouponMapper couponMapper;
    private final BenefitOrderDynamicVerificationCodeMapper orderCodeMapper;
    private final BenefitOrderMapper orderMapper;
    private final BenefitFreeStayVerificationCodeMapper freeStayCodeMapper;
    private final BenefitFreeStayVoucherMapper voucherMapper;
    private final BenefitMerchantMapper merchantMapper;
    private final BenefitMerchantProductMapper productMapper;
    public BenefitVerificationService(
            BenefitQuotaService quotaService,
            BenefitVerifyRecordMapper verifyRecordMapper,
            BenefitCinemaCouponClaimMapper claimMapper,
            BenefitCinemaCouponMapper couponMapper,
            BenefitOrderDynamicVerificationCodeMapper orderCodeMapper,
            BenefitOrderMapper orderMapper,
            BenefitFreeStayVerificationCodeMapper freeStayCodeMapper,
            BenefitFreeStayVoucherMapper voucherMapper,
            BenefitMerchantMapper merchantMapper,
            BenefitMerchantProductMapper productMapper
    ) {
        this.quotaService = quotaService;
        this.verifyRecordMapper = verifyRecordMapper;
        this.claimMapper = claimMapper;
        this.couponMapper = couponMapper;
        this.orderCodeMapper = orderCodeMapper;
        this.orderMapper = orderMapper;
        this.freeStayCodeMapper = freeStayCodeMapper;
        this.voucherMapper = voucherMapper;
        this.merchantMapper = merchantMapper;
        this.productMapper = productMapper;
    }
    @Transactional
    public Map<String, Object> verify(MerchantPrincipal principal, BenefitVerificationRequest request, String idempotencyKey) {
        String key = normalizeIdempotencyKey(idempotencyKey);
        BenefitVerifyRecord existing = verifiedByIdempotencyKey(principal, key);
        if (existing != null) {
            return result(existing, null, null, null, 0);
        }
        String benefitType = quotaService.normalizeBenefitType(request.benefitType());
        String verifyCode = request.verifyCode() == null ? "" : request.verifyCode().trim();
        if (verifyCode.isBlank()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "核销码不能为空");
        }
        if (BenefitQuotaService.MOVIE_TICKET.equals(benefitType)) {
            return verifyMovieTicket(principal, verifyCode, request.remark(), key);
        }
        return verifyHotel(principal, verifyCode, request.remark(), key);
    }
    private Map<String, Object> verifyMovieTicket(MerchantPrincipal principal, String verifyCode, String remark, String idempotencyKey) {
        BenefitCinemaCouponClaim probe = movieClaimByCode(verifyCode, false);
        if (probe == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "电影票核销码不存在");
        }
        if (!principal.merchantId().equals(probe.getMerchantId())) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票不属于当前商户");
        }
        if (!verifyCode.startsWith(BenefitVerificationCodeService.MOVIE_TOKEN_PREFIX)) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票核销码必须由二期生成核销码接口生成");
        }
        if (movieCodeExpired(probe.getUpdatedAt())) {
            expireMovieClaim(probe.getId());
            throw new BusinessException(ErrorCode.CONFLICT, "电影票核销码已过期");
        }
        quotaService.current(BenefitQuotaService.MOVIE_TICKET, probe.getCardId(), probe.getTalentId(), probe.getMerchantId(), null);
        BenefitCinemaCouponClaim claim = movieClaimByCode(verifyCode, true);
        validateMovieClaim(claim);
        int updated = claimMapper.update(null, new UpdateWrapper<BenefitCinemaCouponClaim>()
                .set("code_status", "USED")
                .set("status", "USED")
                .setSql("used_at = now()")
                .setSql("updated_at = now()")
                .eq("id", claim.getId())
                .eq("deleted", 0)
                .eq("exchange_code", verifyCode)
                .apply("upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE'")
                .apply("updated_at > date_sub(now(), interval 300 second)"));
        if (updated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票核销码已使用或已过期");
        }
        BenefitVerifyRecord record = insertRecord(principal, null, claim.getTalentId(), claim.getCardId(), null,
                BenefitQuotaService.MOVIE_TICKET, 0L, 0L,
                sourceRemark("cinemaCouponClaimId=" + claim.getId(), remark), idempotencyKey);
        BenefitQuotaService.QuotaSnapshot quotaAfter = quotaService.current(BenefitQuotaService.MOVIE_TICKET,
                claim.getCardId(), claim.getTalentId(), claim.getMerchantId(), null);
        return result(record, "CINEMA_COUPON_CLAIM", claim.getId(), quotaAfter, 1);
    }
    private Map<String, Object> verifyHotel(MerchantPrincipal principal, String verifyCode, String remark, String idempotencyKey) {
        HotelOrderCodeContext orderCode = hotelOrderCode(verifyCode, principal, false);
        if (orderCode != null) {
            return verifyHotelOrderCode(principal, verifyCode, orderCode, remark, idempotencyKey);
        }
        FreeStayCodeContext freeStayCode = freeStayCode(verifyCode, false);
        if (freeStayCode != null) {
            return verifyFreeStayCode(principal, verifyCode, freeStayCode, remark, idempotencyKey);
        }
        throw new BusinessException(ErrorCode.NOT_FOUND, "酒店核销码不存在");
    }
    private Map<String, Object> verifyHotelOrderCode(MerchantPrincipal principal, String verifyCode, HotelOrderCodeContext probe,
                                                     String remark, String idempotencyKey) {
        if (!belongsToPrincipal(probe.order(), principal)) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店订单不属于当前门店");
        }
        BenefitQuotaService.QuotaSnapshot quotaBefore = quotaService.requireAvailable(BenefitQuotaService.HOTEL,
                probe.order().getCardId(), probe.order().getTalentId(), principal.merchantId(), probe.order().getProductId(), 1);
        HotelOrderCodeContext context = hotelOrderCode(verifyCode, principal, true);
        if (context == null || !belongsToPrincipal(context.order(), principal)) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店订单核销码不属于当前门店");
        }
        BenefitOrderDynamicVerificationCode code = context.code();
        BenefitOrder order = context.order();
        if (intValue(code.getStatus()) != 0 || expiredDateTime(code.getExpiresAt())
                || intValue(order.getOrderStatus()) != 1 || intValue(order.getVerifyStatus()) != 0) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店订单当前不可核销");
        }
        int codeUpdated = orderCodeMapper.update(null, new UpdateWrapper<BenefitOrderDynamicVerificationCode>()
                .set("status", 1)
                .eq("id", code.getId())
                .eq("status", 0)
                .gt("expires_at", LocalDateTime.now()));
        int orderUpdated = orderMapper.update(null, new UpdateWrapper<BenefitOrder>()
                .set("verify_status", 1)
                .eq("id", order.getId())
                .eq("order_status", 1)
                .eq("verify_status", 0)
                .eq("deleted", 0));
        if (codeUpdated != 1 || orderUpdated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店订单已核销或核销码已过期");
        }
        BenefitVerifyRecord record = insertRecord(principal, order.getId(), order.getTalentId(), order.getCardId(),
                order.getBenefitId(), "HOTEL_ORDER", order.getPayAmount(), order.getSubsidyAmount(),
                sourceRemark("orderNo=" + order.getOrderNo(), remark), idempotencyKey);
        return result(record, "BENEFIT_ORDER", order.getId(), afterDeduct(quotaBefore, 1), 1);
    }
    private Map<String, Object> verifyFreeStayCode(MerchantPrincipal principal, String verifyCode, FreeStayCodeContext probe,
                                                   String remark, String idempotencyKey) {
        if (!principal.merchantId().equals(probe.voucher().getMerchantId())) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店核销码不属于当前商户");
        }
        HotelToken token = parseHotelToken(verifyCode);
        Long sourceCardId = token.sourceCardId() == null ? probe.voucher().getCardId() : token.sourceCardId();
        Long productId = token.productId();
        BenefitQuotaService.QuotaSnapshot quotaBefore = quotaService.requireAvailable(BenefitQuotaService.HOTEL,
                sourceCardId, null, principal.merchantId(), productId, 1);
        FreeStayCodeContext context = freeStayCode(verifyCode, true);
        if (context == null || !principal.merchantId().equals(context.voucher().getMerchantId())) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店核销码不属于当前商户");
        }
        BenefitFreeStayVerificationCode code = context.code();
        BenefitFreeStayVoucher voucher = context.voucher();
        if (intValue(code.getStatus()) != 0 || expiredDateTime(code.getExpiresAt())) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店核销码已过期");
        }
        if (intValue(voucher.getStatus()) != 1 || expiredDate(voucher.getValidEnd())
                || intValue(voucher.getUsedCount()) >= intValue(voucher.getTotalCount())) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店权益券次数已用完");
        }
        if (productId != null) {
            decrementHotelProductStock(principal.merchantId(), productId);
        }
        int codeUpdated = freeStayCodeMapper.update(null, new UpdateWrapper<BenefitFreeStayVerificationCode>()
                .set("status", 1)
                .eq("id", code.getId())
                .eq("status", 0)
                .gt("expires_at", LocalDateTime.now()));
        int voucherUpdated = voucherMapper.update(null, new UpdateWrapper<BenefitFreeStayVoucher>()
                .setSql("used_count = used_count + 1")
                .eq("id", voucher.getId())
                .eq("status", 1)
                .ge("valid_end", LocalDate.now())
                .apply("used_count < total_count"));
        if (codeUpdated != 1 || voucherUpdated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店核销码已使用或权益次数已用完");
        }
        BenefitVerifyRecord record = insertRecord(principal, null, quotaBefore.talentId(), quotaBefore.cardId(), null,
                "HOTEL", 0L, 0L,
                sourceRemark("freeStayVoucherId=" + voucher.getId() + productRemark(productId), remark), idempotencyKey);
        return result(record, "FREE_STAY_VERIFICATION_CODE", code.getId(), afterDeduct(quotaBefore, 1), 1);
    }
    private void validateMovieClaim(BenefitCinemaCouponClaim claim) {
        if (claim == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "电影票核销码不存在");
        }
        BenefitCinemaCoupon coupon = couponMapper.selectById(claim.getCouponId());
        if (!"ACTIVE".equals(upper(claim.getCodeStatus())) || movieCodeExpired(claim.getUpdatedAt())) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票核销码已过期");
        }
        if (coupon == null || expiredDate(claim.getValidEnd()) || intValue(coupon.getStatus()) != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票权益券当前不可用");
        }
    }
    private void expireMovieClaim(Long claimId) {
        BenefitCinemaCouponClaim claim = claimMapper.selectOne(new QueryWrapper<BenefitCinemaCouponClaim>()
                .eq("id", claimId)
                .eq("deleted", 0)
                .last("limit 1 for update"));
        if (claim == null) {
            return;
        }
        if ("ACTIVE".equals(upper(claim.getCodeStatus()))
                && startsWithMoviePrefix(claim.getExchangeCode())
                && movieCodeExpired(claim.getUpdatedAt())) {
            int updated = claimMapper.update(null, new UpdateWrapper<BenefitCinemaCouponClaim>()
                    .set("code_status", "EXPIRED")
                    .set("status", "EXPIRED")
                    .eq("id", claimId)
                    .eq("deleted", 0)
                    .apply("upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE'"));
            if (updated == 1) {
                couponMapper.update(null, new UpdateWrapper<BenefitCinemaCoupon>()
                        .setSql("stock = stock + 1")
                        .eq("id", claim.getCouponId())
                        .eq("deleted", 0));
            }
        }
    }
    private BenefitCinemaCouponClaim movieClaimByCode(String code, boolean lock) {
        return claimMapper.selectOne(new QueryWrapper<BenefitCinemaCouponClaim>()
                .eq("deleted", 0)
                .eq("exchange_code", code)
                .last("limit 1" + (lock ? " for update" : "")));
    }
    private HotelOrderCodeContext hotelOrderCode(String token, MerchantPrincipal principal, boolean lock) {
        BenefitOrderDynamicVerificationCode code = orderCodeMapper.selectOne(new QueryWrapper<BenefitOrderDynamicVerificationCode>()
                .eq("code_token", token)
                .last("limit 1" + (lock ? " for update" : "")));
        if (code == null) {
            return null;
        }
        BenefitOrder order = orderMapper.selectOne(new QueryWrapper<BenefitOrder>()
                .eq("id", code.getOrderId())
                .eq("deleted", 0)
                .last("limit 1" + (lock ? " for update" : "")));
        if (order == null || !principal.merchantId().equals(order.getMerchantId())) {
            return null;
        }
        BenefitMerchant merchant = merchantMapper.selectById(order.getMerchantId());
        if (merchant == null || intValue(merchant.getDeleted()) != 0 || !"hotel".equalsIgnoreCase(String.valueOf(merchant.getMerchantType()))) {
            return null;
        }
        return new HotelOrderCodeContext(code, order);
    }
    private FreeStayCodeContext freeStayCode(String token, boolean lock) {
        BenefitFreeStayVerificationCode code = freeStayCodeMapper.selectOne(new QueryWrapper<BenefitFreeStayVerificationCode>()
                .eq("code_token", token)
                .last("limit 1" + (lock ? " for update" : "")));
        if (code == null) {
            return null;
        }
        BenefitFreeStayVoucher voucher = voucherMapper.selectOne(new QueryWrapper<BenefitFreeStayVoucher>()
                .eq("id", code.getVoucherId())
                .last("limit 1" + (lock ? " for update" : "")));
        if (voucher == null) {
            return null;
        }
        return new FreeStayCodeContext(code, voucher);
    }
    private void decrementHotelProductStock(Long merchantId, Long productId) {
        int updated = productMapper.update(null, new UpdateWrapper<BenefitMerchantProduct>()
                .setSql("stock = case when stock = -1 then stock else stock - 1 end")
                .setSql("sales_count = sales_count + 1")
                .eq("id", productId)
                .eq("merchant_id", merchantId)
                .eq("deleted", 0)
                .eq("status", 1)
                .and(wrapper -> wrapper.isNull("valid_start_time").or().le("valid_start_time", LocalDateTime.now()))
                .and(wrapper -> wrapper.isNull("valid_end_time").or().gt("valid_end_time", LocalDateTime.now()))
                .apply("(stock = -1 or stock > 0)"));
        if (updated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店房型或套餐库存不足");
        }
    }
    private BenefitVerifyRecord insertRecord(MerchantPrincipal principal, Long orderId, Long talentId, Long cardId, Long benefitId,
                                             String verifyType, Long verifyAmount, Long subsidyAmount, String remark, String idempotencyKey) {
        BenefitVerifyRecord record = new BenefitVerifyRecord();
        record.setVerifyNo(BusinessIdGenerator.next("V"));
        record.setOrderId(orderId);
        record.setTalentId(talentId);
        record.setCardId(cardId);
        record.setBenefitId(benefitId);
        record.setMerchantId(principal.merchantId());
        record.setStoreId(principal.storeId());
        record.setStaffId(principal.operatorId());
        record.setVerifyType(verifyType);
        record.setVerifyAmount(zeroIfNull(verifyAmount));
        record.setSubsidyAmount(zeroIfNull(subsidyAmount));
        record.setRemark(trimToLength(remark, 512));
        record.setStatus(1);
        record.setIdempotencyKey(idempotencyKey);
        verifyRecordMapper.insert(record);
        return record(record.getId());
    }
    private BenefitVerifyRecord verifiedByIdempotencyKey(MerchantPrincipal principal, String key) {
        return verifyRecordMapper.selectOne(new QueryWrapper<BenefitVerifyRecord>()
                .eq("idempotency_key", key)
                .eq("merchant_id", principal.merchantId())
                .eq("status", 1)
                .last("limit 1"));
    }
    private BenefitVerifyRecord record(Long recordId) {
        BenefitVerifyRecord record = verifyRecordMapper.selectById(recordId);
        if (record == null) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "核销记录保存失败");
        }
        return record;
    }
    private Map<String, Object> result(BenefitVerifyRecord record, String sourceType, Object sourceId,
                                       BenefitQuotaService.QuotaSnapshot quota, int quantity) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("verified", true);
        result.put("verifyRecordId", record.getId());
        result.put("verifyNo", record.getVerifyNo());
        result.put("benefitType", record.getVerifyType());
        result.put("sourceType", sourceType);
        result.put("sourceId", sourceId);
        result.put("quantity", quantity);
        result.put("verifiedAt", record.getVerifyTime());
        result.put("sharedQuotaMode", "AUTO_DEDUCT");
        if (quota != null) {
            result.put("sourceCardId", quota.cardId());
            result.put("sourceTalentId", quota.talentId());
            result.put("familyCard", quota.familyCard());
            result.put("primaryCardId", quota.primaryCardId());
            result.put("primaryTalentId", quota.primaryTalentId());
            result.put("primaryCardNo", quota.primaryCardNo());
            result.put("cardLevel", quota.cardLevel());
            result.put("quotaTotal", quota.total());
            result.put("quotaUsed", quota.used());
            result.put("quotaRemaining", quota.remaining());
            result.put("quotaResetCycle", quota.resetCycle());
            result.put("quotaWindowStart", quota.windowStart());
        }
        return result;
    }
    private BenefitQuotaService.QuotaSnapshot afterDeduct(BenefitQuotaService.QuotaSnapshot quota, int quantity) {
        return new BenefitQuotaService.QuotaSnapshot(
                quota.benefitType(),
                quota.talentId(),
                quota.cardId(),
                quota.cardNo(),
                quota.cardType(),
                quota.primaryTalentId(),
                quota.primaryCardId(),
                quota.primaryCardNo(),
                quota.cardLevel(),
                quota.total(),
                quota.used() + quantity,
                Math.max(quota.remaining() - quantity, 0),
                quota.resetCycle(),
                quota.windowStart(),
                quota.familyCard(),
                quota.sharedCardIds()
        );
    }
    private boolean belongsToPrincipal(BenefitOrder order, MerchantPrincipal principal) {
        Long storeId = order.getStoreId();
        return principal.merchantId().equals(order.getMerchantId()) && (storeId == null || principal.storeId().equals(storeId));
    }
    private HotelToken parseHotelToken(String token) {
        if (token == null || !token.startsWith(BenefitVerificationCodeService.HOTEL_TOKEN_PREFIX + "C")) {
            return new HotelToken(null, null);
        }
        try {
            int cIndex = token.indexOf('C', BenefitVerificationCodeService.HOTEL_TOKEN_PREFIX.length());
            int pIndex = token.indexOf('P', cIndex + 1);
            int splitIndex = token.indexOf('_', pIndex + 1);
            if (cIndex < 0 || pIndex < 0 || splitIndex < 0) {
                return new HotelToken(null, null);
            }
            Long cardId = parsePositiveLong(token.substring(cIndex + 1, pIndex));
            Long productId = parsePositiveLong(token.substring(pIndex + 1, splitIndex));
            return new HotelToken(cardId, productId);
        } catch (RuntimeException ignored) {
            return new HotelToken(null, null);
        }
    }
    private Long parsePositiveLong(String value) {
        long parsed = Long.parseLong(value);
        return parsed > 0 ? parsed : null;
    }
    private boolean movieCodeExpired(LocalDateTime updatedAt) {
        return updatedAt != null && !updatedAt.plusSeconds(BenefitVerificationCodeService.CODE_SECONDS).isAfter(LocalDateTime.now());
    }
    private boolean startsWithMoviePrefix(String code) {
        return code != null && code.startsWith(BenefitVerificationCodeService.MOVIE_TOKEN_PREFIX);
    }
    private boolean expiredDate(LocalDate value) {
        return value != null && value.isBefore(LocalDate.now());
    }
    private boolean expiredDateTime(LocalDateTime value) {
        return value != null && !value.isAfter(LocalDateTime.now());
    }
    private String normalizeIdempotencyKey(String idempotencyKey) {
        if (idempotencyKey == null || idempotencyKey.isBlank()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "幂等键不能为空");
        }
        String key = idempotencyKey.trim();
        if (key.length() > 128) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "幂等键长度不能超过限制");
        }
        return key;
    }
    private String sourceRemark(String source, String remark) {
        String cleaned = remark == null ? "" : remark.trim();
        return cleaned.isBlank() ? source : source + "; " + cleaned;
    }
    private String productRemark(Long productId) {
        return productId == null ? "" : "; productId=" + productId;
    }
    private String trimToLength(String value, int maxLength) {
        if (value == null) {
            return null;
        }
        String trimmed = value.trim();
        return trimmed.length() <= maxLength ? trimmed : trimmed.substring(0, maxLength);
    }
    private String upper(String value) {
        return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
    }
    private int intValue(Object value) {
        return value instanceof Number number ? number.intValue() : 0;
    }
    private long zeroIfNull(Long value) {
        return value == null ? 0L : value;
    }
    private record HotelToken(Long sourceCardId, Long productId) {
    }
    private record HotelOrderCodeContext(BenefitOrderDynamicVerificationCode code, BenefitOrder order) {
    }
    private record FreeStayCodeContext(BenefitFreeStayVerificationCode code, BenefitFreeStayVoucher voucher) {
    }
}
talent-card-server/src/main/java/com/talentcard/server/service/DependentCardApplyAdminService.java
New file
@@ -0,0 +1,185 @@
package com.talentcard.server.service;
import java.time.LocalDateTime;
import java.util.List;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.request.DependentCardAuditRequest;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import com.talentcard.server.entity.DependentCardApply;
import com.talentcard.server.entity.DependentCardApplyStatusLog;
import com.talentcard.server.mapper.DependentCardApplyMapper;
import com.talentcard.server.mapper.DependentCardApplyStatusLogMapper;
import com.talentcard.server.response.DependentCardApplyAdminResponse;
import com.talentcard.server.response.DependentCardApplyStatusLogResponse;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class DependentCardApplyAdminService {
    private static final String ELITE_CARD_LEVEL = "卓越人才卡";
    private static final int MAX_FAMILY_CARD_COUNT = 2;
    private final DependentCardApplyMapper mapper;
    private final DependentCardApplyStatusLogMapper statusLogMapper;
    public DependentCardApplyAdminService(
            DependentCardApplyMapper mapper,
            DependentCardApplyStatusLogMapper statusLogMapper
    ) {
        this.mapper = mapper;
        this.statusLogMapper = statusLogMapper;
    }
    @Transactional(readOnly = true)
    public PageResult<DependentCardApplyAdminResponse> page(
            Long talentCardId,
            Integer status,
            int page,
            int size
    ) {
        validateStatusFilter(status);
        int safePage = Math.max(page, 1);
        int safeSize = Math.min(Math.max(size, 1), 100);
        LambdaQueryWrapper<DependentCardApply> query = Wrappers.<DependentCardApply>lambdaQuery()
                .eq(talentCardId != null, DependentCardApply::getTalentCardId, talentCardId)
                .eq(status != null, DependentCardApply::getStatus, status);
        long total = mapper.selectCount(query);
        if (total == 0) {
            return new PageResult<>(List.of(), safePage, safeSize, 0);
        }
        long offset = (long) (safePage - 1) * safeSize;
        List<DependentCardApplyAdminResponse> records = mapper.selectList(
                        query.orderByDesc(DependentCardApply::getApplyTime)
                                .orderByDesc(DependentCardApply::getId)
                                .last("limit " + safeSize + " offset " + offset)
                ).stream()
                .map(DependentCardApplyAdminResponse::from)
                .toList();
        return new PageResult<>(records, safePage, safeSize, total);
    }
    @Transactional(readOnly = true)
    public DependentCardApplyAdminResponse detail(Long id) {
        return DependentCardApplyAdminResponse.from(requireApplication(id));
    }
    @Transactional(readOnly = true)
    public List<DependentCardApplyStatusLogResponse> statusLogs(Long id) {
        DependentCardApply apply = requireApplication(id);
        return statusLogMapper.selectList(
                        Wrappers.<DependentCardApplyStatusLog>lambdaQuery()
                                .eq(DependentCardApplyStatusLog::getApplyId, apply.getId())
                                .orderByAsc(DependentCardApplyStatusLog::getCreatedAt)
                                .orderByAsc(DependentCardApplyStatusLog::getId)
                ).stream()
                .map(DependentCardApplyStatusLogResponse::from)
                .toList();
    }
    @Transactional
    public DependentCardApplyAdminResponse audit(
            Long id,
            DependentCardAuditRequest request,
            Long operatorId
    ) {
        int targetStatus = request.status();
        if (targetStatus != DependentCardApply.STATUS_APPROVED
                && targetStatus != DependentCardApply.STATUS_REJECTED) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "审核状态只能为1通过或2驳回");
        }
        String auditRemark = trimToNull(request.auditRemark());
        if (targetStatus == DependentCardApply.STATUS_REJECTED && auditRemark == null) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "驳回申请时必须填写审核备注");
        }
        DependentCardApply apply = mapper.selectByIdForUpdate(id);
        if (apply == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "家属附属卡申请不存在");
        }
        if (!Integer.valueOf(DependentCardApply.STATUS_PENDING_REVIEW).equals(apply.getStatus())) {
            throw new BusinessException(ErrorCode.CONFLICT, "该申请已审核或已解绑");
        }
        if (targetStatus == DependentCardApply.STATUS_APPROVED) {
            ensureApprovalSlot(apply);
        }
        LocalDateTime auditTime = LocalDateTime.now();
        int updated = mapper.update(
                null,
                Wrappers.<DependentCardApply>lambdaUpdate()
                        .eq(DependentCardApply::getId, id)
                        .eq(DependentCardApply::getStatus, DependentCardApply.STATUS_PENDING_REVIEW)
                        .set(DependentCardApply::getStatus, targetStatus)
                        .set(DependentCardApply::getAuditBy, operatorId)
                        .set(DependentCardApply::getAuditTime, auditTime)
                        .set(DependentCardApply::getAuditRemark, auditRemark)
        );
        if (updated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "申请状态已变化,请刷新后重试");
        }
        if (statusLogMapper.insert(new DependentCardApplyStatusLog(
                apply.getId(),
                apply.getApplyNo(),
                DependentCardApply.STATUS_PENDING_REVIEW,
                targetStatus,
                "ADMIN",
                operatorId,
                auditRemark
        )) != 1) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "审核状态记录保存失败");
        }
        return DependentCardApplyAdminResponse.from(mapper.selectById(id));
    }
    private void ensureApprovalSlot(DependentCardApply apply) {
        DependentCardApply ownerCard = mapper.selectOwnerCard(apply.getTalentCardId());
        if (ownerCard == null) {
            throw new BusinessException(ErrorCode.CONFLICT, "主卡不存在或已失效,不能审核通过");
        }
        if (!ELITE_CARD_LEVEL.equals(ownerCard.getCardLevel())
                || !Integer.valueOf(1).equals(ownerCard.getIsFamilyCardEnabled())) {
            throw new BusinessException(ErrorCode.CONFLICT, "该主卡当前无家属附属卡权限");
        }
        Integer configuredLimit = ownerCard.getFamilyCardLimit();
        if (configuredLimit == null || configuredLimit <= 0) {
            throw new BusinessException(ErrorCode.CONFLICT, "该主卡未配置家属附属卡名额");
        }
        int familyCardLimit = Math.min(configuredLimit, MAX_FAMILY_CARD_COUNT);
        long approvedCount = mapper.selectCount(
                Wrappers.<DependentCardApply>lambdaQuery()
                        .eq(DependentCardApply::getTalentCardId, apply.getTalentCardId())
                        .eq(DependentCardApply::getStatus, DependentCardApply.STATUS_APPROVED)
        );
        if (approvedCount >= familyCardLimit) {
            throw new BusinessException(ErrorCode.CONFLICT, "该主卡已达到家属附属卡绑定上限");
        }
    }
    private DependentCardApply requireApplication(Long id) {
        if (id == null || id <= 0) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "申请ID不能为空");
        }
        DependentCardApply apply = mapper.selectById(id);
        if (apply == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "家属附属卡申请不存在");
        }
        return apply;
    }
    private void validateStatusFilter(Integer status) {
        if (status != null && (status < DependentCardApply.STATUS_PENDING_REVIEW
                || status > DependentCardApply.STATUS_CANCELLED)) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "申请状态不正确");
        }
    }
    private String trimToNull(String value) {
        if (value == null || value.isBlank()) {
            return null;
        }
        return value.trim();
    }
}
talent-card-server/src/main/java/com/talentcard/server/service/DependentCardApplyService.java
New file
@@ -0,0 +1,192 @@
package com.talentcard.server.service;
import java.util.List;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import com.talentcard.server.entity.DependentCardApply;
import com.talentcard.server.entity.DependentCardApplyStatusLog;
import com.talentcard.server.mapper.DependentCardApplyMapper;
import com.talentcard.server.mapper.DependentCardApplyStatusLogMapper;
import com.talentcard.server.request.DependentCardApplyRequest;
import com.talentcard.server.request.DependentCardUnbindRequest;
import com.talentcard.server.response.DependentCardApplyItemResponse;
import com.talentcard.server.response.DependentCardApplyResponse;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class DependentCardApplyService {
    private static final String ELITE_CARD_LEVEL = "卓越人才卡";
    private static final int MAX_APPLICATION_COUNT = 3;
    private static final int MAX_FAMILY_CARD_COUNT = 2;
    private final DependentCardApplyMapper mapper;
    private final DependentCardApplyStatusLogMapper statusLogMapper;
    public DependentCardApplyService(
            DependentCardApplyMapper mapper,
            DependentCardApplyStatusLogMapper statusLogMapper
    ) {
        this.mapper = mapper;
        this.statusLogMapper = statusLogMapper;
    }
    @Transactional
    public DependentCardApplyResponse submit(DependentCardApplyRequest request) {
        DependentCardApply ownerCard = ownerCard(request.talentCardId());
        int familyCardLimit = familyCardLimit(ownerCard);
        long applicationCount = mapper.selectCount(
                Wrappers.<DependentCardApply>lambdaQuery()
                        .eq(DependentCardApply::getTalentCardId, request.talentCardId())
                        .in(
                                DependentCardApply::getStatus,
                                DependentCardApply.STATUS_PENDING_REVIEW,
                                DependentCardApply.STATUS_APPROVED,
                                DependentCardApply.STATUS_REJECTED
                        )
        );
        if (applicationCount >= MAX_APPLICATION_COUNT) {
            throw new BusinessException(ErrorCode.CONFLICT, "同一张主卡最多提交3个家属附属卡申请");
        }
        long occupiedCount = mapper.selectCount(
                Wrappers.<DependentCardApply>lambdaQuery()
                        .eq(DependentCardApply::getTalentCardId, request.talentCardId())
                        .in(
                                DependentCardApply::getStatus,
                                DependentCardApply.STATUS_PENDING_REVIEW,
                                DependentCardApply.STATUS_APPROVED
                        )
        );
        if (occupiedCount >= familyCardLimit) {
            throw new BusinessException(
                    ErrorCode.CONFLICT,
                    "该主卡家属附属卡名额已满,最多绑定" + familyCardLimit + "个"
            );
        }
        DependentCardApply apply = new DependentCardApply(
                BusinessIdGenerator.next("DCA"),
                number(ownerCard.getTalentId()),
                number(ownerCard.getTalentCardId()),
                text(ownerCard.getTalentName()),
                text(ownerCard.getTalentMobile()),
                text(ownerCard.getTalentCardNo()),
                text(ownerCard.getCardLevel()),
                request.dependentName().trim(),
                request.dependentMobile().trim(),
                request.dependentIdCardNo().trim().toUpperCase(),
                request.relation().trim()
        );
        if (mapper.insert(apply) != 1) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "家属附属卡申请提交失败");
        }
        recordStatus(apply, null, DependentCardApply.STATUS_PENDING_REVIEW, "TALENT", apply.getTalentId(), "小程序提交申请");
        return DependentCardApplyResponse.from(apply);
    }
    @Transactional(readOnly = true)
    public List<DependentCardApplyItemResponse> list(Long talentCardId) {
        requireTalentCardId(talentCardId);
        return mapper.selectList(
                        Wrappers.<DependentCardApply>lambdaQuery()
                                .eq(DependentCardApply::getTalentCardId, talentCardId)
                                .orderByDesc(DependentCardApply::getApplyTime)
                                .orderByDesc(DependentCardApply::getId)
                ).stream()
                .map(DependentCardApplyItemResponse::from)
                .toList();
    }
    @Transactional
    public DependentCardApplyItemResponse unbind(Long id, DependentCardUnbindRequest request) {
        requireTalentCardId(request.talentCardId());
        DependentCardApply apply = mapper.selectByIdForUpdate(id);
        if (apply == null || !request.talentCardId().equals(apply.getTalentCardId())) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "家属附属卡申请不存在");
        }
//        if (!Integer.valueOf(DependentCardApply.STATUS_APPROVED).equals(apply.getStatus())) {
//            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "仅已通过审核的家属附属卡可以解绑");
//        }
        int updated = mapper.update(
                null,
                Wrappers.<DependentCardApply>lambdaUpdate()
                        .eq(DependentCardApply::getId, id)
                        .eq(DependentCardApply::getTalentCardId, request.talentCardId())
                        .eq(DependentCardApply::getStatus, DependentCardApply.STATUS_APPROVED)
                        .set(DependentCardApply::getStatus, DependentCardApply.STATUS_CANCELLED)
        );
        if (updated != 1) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "家属附属卡状态已变化,请刷新后重试");
        }
        recordStatus(
                apply,
                DependentCardApply.STATUS_APPROVED,
                DependentCardApply.STATUS_CANCELLED,
                "TALENT",
                apply.getTalentId(),
                "小程序用户主动解绑"
        );
        return DependentCardApplyItemResponse.from(mapper.selectById(id));
    }
    private DependentCardApply ownerCard(Long talentCardId) {
        requireTalentCardId(talentCardId);
        DependentCardApply card = mapper.selectOwnerCard(talentCardId);
        if (card == null) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "主卡不存在或已失效");
        }
        return card;
    }
    private int familyCardLimit(DependentCardApply card) {
        if (!ELITE_CARD_LEVEL.equals(card.getCardLevel())) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "仅卓越人才卡可申请家属附属卡");
        }
        if (!Integer.valueOf(1).equals(card.getIsFamilyCardEnabled())) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "该主卡未开通家属附属卡申请权限");
        }
        Integer configuredLimit = card.getFamilyCardLimit();
        if (configuredLimit == null || configuredLimit <= 0) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "该主卡未配置家属附属卡名额");
        }
        return Math.min(configuredLimit, MAX_FAMILY_CARD_COUNT);
    }
    private void recordStatus(
            DependentCardApply apply,
            Integer fromStatus,
            Integer toStatus,
            String operatorType,
            Long operatorId,
            String remark
    ) {
        if (statusLogMapper.insert(new DependentCardApplyStatusLog(
                apply.getId(),
                apply.getApplyNo(),
                fromStatus,
                toStatus,
                operatorType,
                operatorId,
                remark
        )) != 1) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "家属附属卡状态记录保存失败");
        }
    }
    private void requireTalentCardId(Long talentCardId) {
        if (talentCardId == null || talentCardId <= 0) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "主卡ID不能为空");
        }
    }
    private Long number(Object value) {
        return value instanceof Number number ? number.longValue() : null;
    }
    private String text(Object value) {
        return value == null ? null : String.valueOf(value);
    }
}
talent-card-server/src/main/java/com/talentcard/server/service/ServiceAppointmentAdminService.java
New file
@@ -0,0 +1,407 @@
package com.talentcard.server.service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.request.ServiceAppointmentStatusUpdateRequest;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import com.talentcard.server.common.SensitiveDataCrypto;
import com.talentcard.server.entity.ChildInformation;
import com.talentcard.server.entity.ServiceApplication;
import com.talentcard.server.mapper.ChildInformationMapper;
import com.talentcard.server.mapper.ServiceApplicationMapper;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ServiceAppointmentAdminService {
    private static final List<String> PENDING_STATUSES = List.of("待处理", "待审核");
    private static final List<String> REJECTED_STATUSES = List.of("已驳回", "审核不通过", "已取消");
    private final ServiceApplicationMapper applicationMapper;
    private final ChildInformationMapper childInformationMapper;
    private final SensitiveDataCrypto sensitiveDataCrypto;
    public ServiceAppointmentAdminService(
            ServiceApplicationMapper applicationMapper,
            ChildInformationMapper childInformationMapper,
            SensitiveDataCrypto sensitiveDataCrypto
    ) {
        this.applicationMapper = applicationMapper;
        this.childInformationMapper = childInformationMapper;
        this.sensitiveDataCrypto = sensitiveDataCrypto;
    }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> page(String applicationType, String keyword, String serviceKeyword,
                                                String cardLevel, String status, String submittedStart,
                                                String submittedEnd, int page, int size) {
        int safePage = Math.max(page, 1);
        int safeSize = Math.min(Math.max(size, 1), 100);
        QueryWrapper<ServiceApplication> countWrapper = filter(applicationType, keyword, serviceKeyword, cardLevel, status, submittedStart, submittedEnd);
        Long total = applicationMapper.selectCount(countWrapper);
        QueryWrapper<ServiceApplication> pageWrapper = filter(applicationType, keyword, serviceKeyword, cardLevel, status, submittedStart, submittedEnd)
                .orderByDesc("submitted_at", "id")
                .last("limit " + safeSize + " offset " + ((safePage - 1) * safeSize));
        List<Map<String, Object>> records = applicationMapper.selectList(pageWrapper).stream()
                .map(this::listView)
                .toList();
        return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total);
    }
    @Transactional(readOnly = true)
    public Map<String, Object> detail(Long id) {
        ServiceApplication application = application(id);
        return detailView(application);
    }
    @Transactional
    public Map<String, Object> updateStatus(Long id, ServiceAppointmentStatusUpdateRequest request, Long operatorId) {
        ServiceApplication current = applicationMapper.selectByIdForUpdate(id);
        if (current == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "预约申请不存在");
        }
        String nextStatus = normalizeStatus(request.status(), current.getApplicationType());
        String remark = first(request.remark(), request.rejectReason());
        if (REJECTED_STATUSES.contains(nextStatus) && remark.isBlank()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "拒绝或驳回时必须填写处理备注");
        }
        String handlerName = first(request.handlerName(), operatorId == null ? "" : "管理员" + operatorId, "管理员");
        LocalDateTime handledAt = PENDING_STATUSES.contains(nextStatus) ? null : LocalDateTime.now();
        UpdateWrapper<ServiceApplication> update = new UpdateWrapper<ServiceApplication>()
                .set("status", nextStatus)
                .set("handled_at", handledAt)
                .set("handler_name", handledAt == null ? null : handlerName)
                .set("reject_reason", remark.isBlank() ? null : remark)
                .eq("id", id)
                .eq("deleted", 0);
        int updated = applicationMapper.update(null, update);
        if (updated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "预约申请状态已变化,请刷新后重试");
        }
        syncChildAudit(current, nextStatus, remark);
        return detail(id);
    }
    @Transactional(readOnly = true)
    public byte[] exportExcel(String applicationType, String keyword, String serviceKeyword,
                              String cardLevel, String status, String submittedStart, String submittedEnd) {
        QueryWrapper<ServiceApplication> wrapper = filter(applicationType, keyword, serviceKeyword, cardLevel, status, submittedStart, submittedEnd)
                .orderByDesc("submitted_at", "id");
        List<ServiceApplication> applications = applicationMapper.selectList(wrapper);
        String[] headers = {
                "序号", "申请编号", "预约类型", "状态", "持卡人", "持卡人手机号", "人才卡等级", "人才卡号",
                "服务机构", "联系电话", "预约内容", "预约时间", "申请人/配偶/子女", "申请人手机号",
                "证件号", "子女性别", "子女出生日期", "处理人", "处理时间", "处理备注", "提交时间"
        };
        try (Workbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
            Sheet sheet = workbook.createSheet("预约申请");
            sheet.createFreezePane(0, 1);
            CellStyle headerStyle = headerStyle(workbook);
            Row headerRow = sheet.createRow(0);
            for (int index = 0; index < headers.length; index++) {
                headerRow.createCell(index).setCellValue(headers[index]);
                headerRow.getCell(index).setCellStyle(headerStyle);
            }
            int rowIndex = 1;
            for (ServiceApplication application : applications) {
                Row row = sheet.createRow(rowIndex);
                List<String> values = exportValues(rowIndex, application);
                for (int index = 0; index < values.size(); index++) {
                    row.createCell(index).setCellValue(values.get(index));
                }
                rowIndex++;
            }
            int[] widths = { 8, 24, 18, 14, 16, 18, 16, 20, 28, 18, 24, 28, 20, 18, 24, 12, 16, 16, 22, 30, 22 };
            for (int index = 0; index < widths.length; index++) {
                sheet.setColumnWidth(index, widths[index] * 256);
            }
            workbook.write(output);
            return output.toByteArray();
        } catch (IOException exception) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "预约申请导出失败");
        }
    }
    private QueryWrapper<ServiceApplication> filter(String applicationType, String keyword, String serviceKeyword,
                                                    String cardLevel, String status, String submittedStart, String submittedEnd) {
        QueryWrapper<ServiceApplication> wrapper = new QueryWrapper<ServiceApplication>().eq("deleted", 0);
        String type = normalizeFilterType(applicationType);
        if (!type.isBlank()) {
            wrapper.eq("application_type", type);
        }
        String key = clean(keyword);
        if (!key.isBlank()) {
            String like = "%" + key + "%";
            wrapper.and(query -> query.like("application_no", like)
                    .or().like("talent_name", like)
                    .or().like("talent_mobile", like)
                    .or().like("card_no", like)
                    .or().like("applicant_name", like)
                    .or().like("applicant_mobile", like)
                    .or().like("spouse_name", like)
                    .or().like("child_name", like));
        }
        String service = clean(serviceKeyword);
        if (!service.isBlank()) {
            String like = "%" + service + "%";
            wrapper.and(query -> query.like("merchant_name", like)
                    .or().like("hospital_name", like)
                    .or().like("package_name", like)
                    .or().like("product_name", like));
        }
        if (!clean(cardLevel).isBlank()) {
            wrapper.eq("talent_level", clean(cardLevel));
        }
        if (!clean(status).isBlank()) {
            wrapper.eq("status", clean(status));
        }
        LocalDate start = localDate(submittedStart, "提交开始日期格式不正确");
        LocalDate end = localDate(submittedEnd, "提交结束日期格式不正确");
        if (start != null) {
            wrapper.ge("submitted_at", start.atStartOfDay());
        }
        if (end != null) {
            wrapper.lt("submitted_at", end.plusDays(1).atStartOfDay());
        }
        return wrapper;
    }
    private Map<String, Object> listView(ServiceApplication application) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("id", application.getId());
        result.put("applicationNo", application.getApplicationNo());
        result.put("applicationType", application.getApplicationType());
        result.put("applicationTypeName", typeName(application.getApplicationType()));
        result.put("status", application.getStatus());
        result.put("talentName", application.getTalentName());
        result.put("talentMobile", application.getTalentMobile());
        result.put("talentLevel", application.getTalentLevel());
        result.put("cardNo", application.getCardNo());
        result.put("serviceName", serviceName(application));
        result.put("appointmentContent", appointmentContent(application));
        result.put("appointmentTime", first(application.getExpectedTime(), "以专员通知为准"));
        result.put("applicantName", participantName(application));
        result.put("submittedAt", application.getSubmittedAt());
        result.put("handledAt", application.getHandledAt());
        result.put("handlerName", application.getHandlerName());
        return result;
    }
    private Map<String, Object> detailView(ServiceApplication application) {
        Map<String, Object> result = listView(application);
        result.put("talentId", application.getTalentId());
        result.put("cardId", application.getCardId());
        result.put("merchantId", application.getMerchantId());
        result.put("contactMobile", application.getContactMobile());
        result.put("packageName", application.getPackageName());
        result.put("productName", application.getProductName());
        result.put("applicantMobile", sensitiveDataCrypto.decrypt(application.getApplicantMobile()));
        result.put("applicantIdCardNo", sensitiveDataCrypto.decrypt(application.getApplicantIdCardNo()));
        result.put("checkInDate", application.getCheckInDate());
        result.put("checkOutDate", application.getCheckOutDate());
        result.put("guestCount", application.getGuestCount());
        result.put("subsidy", application.getSubsidy());
        result.put("remark", application.getRejectReason());
        result.put("estimatedProcessingTime", application.getEstimatedProcessingTime());
        result.put("institutionAddress", application.getInstitutionAddressSnapshot());
        result.put("institutionLongitude", application.getInstitutionLongitudeSnapshot());
        result.put("institutionLatitude", application.getInstitutionLatitudeSnapshot());
        Map<String, Object> spouse = new LinkedHashMap<>();
        spouse.put("name", first(application.getSpouseName()));
        spouse.put("mobile", sensitiveDataCrypto.decrypt(application.getSpouseMobile()));
        spouse.put("idCardNo", sensitiveDataCrypto.decrypt(application.getSpouseIdCardNo()));
        spouse.put("politicalStatus", first(application.getSpousePoliticalStatus()));
        spouse.put("education", first(application.getSpouseEducation()));
        spouse.put("schoolMajor", first(application.getSpouseSchoolMajor()));
        spouse.put("workUnit", first(application.getSpouseWorkUnit()));
        spouse.put("inInstitution", first(application.getSpouseInInstitution()));
        spouse.put("workExperience", first(application.getSpouseWorkExperience()));
        result.put("spouse", spouse);
        Map<String, Object> child = new LinkedHashMap<>();
        child.put("id", application.getChildId());
        child.put("name", first(application.getChildName()));
        child.put("gender", first(application.getChildGender()));
        child.put("birthDate", application.getChildBirthDate());
        child.put("proofFileIds", proofFileIds(application.getProofFileIds()));
        result.put("child", child);
        return result;
    }
    private List<String> exportValues(int index, ServiceApplication application) {
        return List.of(
                String.valueOf(index),
                first(application.getApplicationNo()),
                typeName(application.getApplicationType()),
                first(application.getStatus()),
                first(application.getTalentName()),
                first(application.getTalentMobile()),
                first(application.getTalentLevel()),
                first(application.getCardNo()),
                serviceName(application),
                first(application.getContactMobile()),
                appointmentContent(application),
                first(application.getExpectedTime()),
                participantName(application),
                sensitiveDataCrypto.decrypt(application.getApplicantMobile()),
                sensitiveDataCrypto.decrypt(first(application.getApplicantIdCardNo(), application.getSpouseIdCardNo())),
                first(application.getChildGender()),
                application.getChildBirthDate() == null ? "" : application.getChildBirthDate().toString(),
                first(application.getHandlerName()),
                application.getHandledAt() == null ? "" : application.getHandledAt().toString(),
                first(application.getRejectReason()),
                application.getSubmittedAt() == null ? "" : application.getSubmittedAt().toString()
        );
    }
    private void syncChildAudit(ServiceApplication application, String status, String remark) {
        if (!ServiceAppointmentApplicationService.TYPE_CHILD.equals(application.getApplicationType()) || application.getChildId() == null) {
            return;
        }
        String auditStatus = switch (status) {
            case "审核通过", "已处理" -> "已通过";
            case "审核不通过", "已驳回", "已取消" -> "审核不通过";
            case "待审核", "待处理" -> "待审核";
            default -> null;
        };
        if (auditStatus == null) {
            return;
        }
        childInformationMapper.update(null, new UpdateWrapper<ChildInformation>()
                .set("audit_status", auditStatus)
                .set("reject_reason", "审核不通过".equals(auditStatus) ? remark : null)
                .eq("id", application.getChildId())
                .eq("talent_id", application.getTalentId())
                .eq("deleted", 0));
    }
    private ServiceApplication application(Long id) {
        if (id == null || id <= 0) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "预约申请ID不能为空");
        }
        ServiceApplication application = applicationMapper.selectOne(new QueryWrapper<ServiceApplication>()
                .eq("id", id)
                .eq("deleted", 0)
                .last("limit 1"));
        if (application == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "预约申请不存在");
        }
        return application;
    }
    private String normalizeStatus(String raw, String applicationType) {
        String status = clean(raw);
        boolean child = ServiceAppointmentApplicationService.TYPE_CHILD.equals(applicationType);
        return switch (status) {
            case "待处理", "处理中", "已处理", "已驳回", "已取消", "待审核", "审核通过", "审核不通过" -> status;
            case "通过", "已通过", "完成", "处理完成" -> child ? "审核通过" : "已处理";
            case "拒绝", "驳回", "不通过", "审核拒绝" -> child ? "审核不通过" : "已驳回";
            default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "处理状态不正确");
        };
    }
    private String normalizeFilterType(String raw) {
        String value = clean(raw).toUpperCase(Locale.ROOT).replace('-', '_');
        if (value.isBlank()) {
            return "";
        }
        return switch (value) {
            case "MEDICAL", "MEDICAL_CHECKUP", "MEDICAL_RESERVATION", "PHYSICAL_EXAM", "HEALTH_CHECK" -> ServiceAppointmentApplicationService.TYPE_MEDICAL;
            case "FAST_TRACK", "QUICK_CHANNEL", "GREEN_CHANNEL", "MEDICAL_APPLICATION", "HOSPITAL", "HOSPITAL_APPOINTMENT" -> ServiceAppointmentApplicationService.TYPE_FAST_TRACK;
            case "HOTEL", "HOTEL_RESERVATION", "HOTEL_BOOKING" -> ServiceAppointmentApplicationService.TYPE_HOTEL;
            case "SPOUSE", "SPOUSE_EMPLOYMENT", "EMPLOYMENT" -> ServiceAppointmentApplicationService.TYPE_SPOUSE;
            case "CHILD", "CHILDREN", "CHILD_ENTRUSTMENT", "CHILDREN_ENTRUSTMENT", "STUDENT_CARE", "STUDENT_ENTRUSTMENT" -> ServiceAppointmentApplicationService.TYPE_CHILD;
            default -> raw;
        };
    }
    private String typeName(String type) {
        return switch (type) {
            case ServiceAppointmentApplicationService.TYPE_MEDICAL -> "体检预约";
            case ServiceAppointmentApplicationService.TYPE_FAST_TRACK -> "医院快捷通道";
            case ServiceAppointmentApplicationService.TYPE_HOTEL -> "酒店预约";
            case ServiceAppointmentApplicationService.TYPE_SPOUSE -> "配偶推荐就业";
            case ServiceAppointmentApplicationService.TYPE_CHILD -> "学生托管";
            default -> "预约申请";
        };
    }
    private String serviceName(ServiceApplication application) {
        return first(application.getHospitalName(), application.getMerchantName(), application.getParkingLot(), "人才服务机构");
    }
    private String appointmentContent(ServiceApplication application) {
        return first(application.getProductName(), application.getPackageName(), typeName(application.getApplicationType()));
    }
    private String participantName(ServiceApplication application) {
        return first(application.getApplicantName(), application.getSpouseName(), application.getChildName(), application.getTalentName());
    }
    private List<String> proofFileIds(String value) {
        if (value == null || value.isBlank()) {
            return List.of();
        }
        List<String> result = new ArrayList<>();
        for (String item : value.split(",")) {
            String cleaned = clean(item);
            if (!cleaned.isBlank()) {
                result.add(cleaned);
            }
        }
        return result;
    }
    private LocalDate localDate(String value, String message) {
        String text = clean(value);
        if (text.isBlank()) {
            return null;
        }
        try {
            return LocalDate.parse(text);
        } catch (DateTimeParseException exception) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, message);
        }
    }
    private CellStyle headerStyle(Workbook workbook) {
        CellStyle style = workbook.createCellStyle();
        style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
        style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
        return style;
    }
    private String first(String... values) {
        for (String value : values) {
            String text = clean(value);
            if (!text.isBlank()) {
                return text;
            }
        }
        return "";
    }
    private String clean(String value) {
        return value == null ? "" : value.trim();
    }
}
talent-card-server/src/main/java/com/talentcard/server/service/ServiceAppointmentApplicationService.java
New file
@@ -0,0 +1,583 @@
package com.talentcard.server.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeParseException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.request.ServiceAppointmentSubmitRequest;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import com.talentcard.server.common.SensitiveDataCrypto;
import com.talentcard.server.entity.ChildInformation;
import com.talentcard.server.entity.ServiceApplication;
import com.talentcard.server.mapper.ChildInformationMapper;
import com.talentcard.server.mapper.ServiceApplicationMapper;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ServiceAppointmentApplicationService {
    public static final String TYPE_MEDICAL = "MEDICAL_RESERVATION";
    public static final String TYPE_FAST_TRACK = "FAST_TRACK";
    public static final String TYPE_HOTEL = "HOTEL_RESERVATION";
    public static final String TYPE_SPOUSE = "SPOUSE_EMPLOYMENT";
    public static final String TYPE_CHILD = "CHILD_ENTRUSTMENT";
    private final ServiceApplicationMapper applicationMapper;
    private final ChildInformationMapper childInformationMapper;
    private final SensitiveDataCrypto sensitiveDataCrypto;
    public ServiceAppointmentApplicationService(
            ServiceApplicationMapper applicationMapper,
            ChildInformationMapper childInformationMapper,
            SensitiveDataCrypto sensitiveDataCrypto
    ) {
        this.applicationMapper = applicationMapper;
        this.childInformationMapper = childInformationMapper;
        this.sensitiveDataCrypto = sensitiveDataCrypto;
    }
    @Transactional
    public Map<String, Object> submit(ServiceAppointmentSubmitRequest request, String idempotencyKey) {
        String key = idempotencyKey(idempotencyKey);
        ServiceApplication duplicate = applicationByIdempotencyKey(key);
        if (duplicate != null) {
            return appResult(duplicate);
        }
        String applicationType = normalizeType(request.applicationType());
        Map<String, Object> card = activeCard(request.talentId(), request.cardId());
        ServiceApplication application = baseApplication(applicationType, key, card, request.remark());
        switch (applicationType) {
            case TYPE_MEDICAL -> fillMedical(application, request);
            case TYPE_FAST_TRACK -> fillFastTrack(application, request);
            case TYPE_HOTEL -> fillHotel(application, request);
            case TYPE_SPOUSE -> fillSpouseEmployment(application, request);
            case TYPE_CHILD -> fillChildEntrustment(application, request, key);
            default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "预约类型不正确");
        }
        try {
            applicationMapper.insert(application);
        } catch (DuplicateKeyException exception) {
            ServiceApplication existing = applicationByIdempotencyKey(key);
            if (existing != null) {
                return appResult(existing);
            }
            throw exception;
        }
        ServiceApplication saved = applicationMapper.selectById(application.getId());
        if (saved == null) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "预约申请保存失败");
        }
        return appResult(saved);
    }
    private ServiceApplication baseApplication(String applicationType, String idempotencyKey, Map<String, Object> card, String remark) {
        ServiceApplication application = new ServiceApplication();
        application.setApplicationNo(BusinessIdGenerator.next("SA"));
        application.setApplicationType(applicationType);
        application.setIdempotencyKey(idempotencyKey);
        application.setTalentId(longValue(card, "talentId", "talent_id"));
        application.setCardId(longValue(card, "cardId", "card_id"));
        application.setTalentName(text(card, "talentName", "talent_name"));
        application.setTalentMobile(text(card, "talentMobile", "talent_mobile"));
        application.setTalentLevel(text(card, "talentLevel", "talent_level"));
        application.setCardNo(text(card, "cardNo", "card_no"));
        application.setApplicantIdCardNo(blankToNull(text(card, "talentIdCardNo", "talent_id_card_no")));
        application.setSubmittedAt(LocalDateTime.now());
        application.setStatus(TYPE_CHILD.equals(applicationType) ? "待审核" : "待处理");
        application.setRejectReason(blankToNull(clean(remark)));
        application.setDeleted(0);
        return application;
    }
    private void fillMedical(ServiceApplication application, ServiceAppointmentSubmitRequest request) {
        Map<String, Object> merchant = optionalMerchant(request.merchantId(), "medical", "medical", "体检医院不存在或暂未开放");
        applyInstitution(application, merchant, request.hospitalName(), request.contactMobile(), true);
        application.setApplicantName(first(clean(request.applicantName()), application.getTalentName()));
        application.setApplicantMobile(first(clean(request.applicantMobile()), application.getTalentMobile()));
        application.setApplicantIdCardNo(blankToNull(first(request.applicantIdCardNo(), application.getApplicantIdCardNo())));
        applyMedicalPackage(application, request.packageId(), request.packageName());
        String expected = first(clean(request.expectedTime()), clean(request.expectedDate()));
        if (expected.isBlank()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择期望预约日期");
        }
        application.setExpectedTime(expected);
        application.setEstimatedProcessingTime("提交后1个工作日内由医院确认");
    }
    private void fillFastTrack(ServiceApplication application, ServiceAppointmentSubmitRequest request) {
        Map<String, Object> merchant = optionalMerchant(request.merchantId(), "medical", "medical", "医院快捷通道服务不存在或暂未开放");
        applyInstitution(application, merchant, request.hospitalName(), request.contactMobile(), true);
        application.setPackageName(first(clean(request.packageName()), "医院快捷通道"));
        application.setProductName(first(clean(request.productName()), "快捷通道预约"));
        application.setApplicantName(first(clean(request.applicantName()), application.getTalentName()));
        application.setApplicantMobile(first(clean(request.applicantMobile()), application.getTalentMobile()));
        application.setApplicantIdCardNo(blankToNull(first(request.applicantIdCardNo(), application.getApplicantIdCardNo())));
        String expected = first(clean(request.expectedTime()), clean(request.expectedDate()));
        if (expected.isBlank()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择期望预约日期");
        }
        application.setExpectedTime(expected);
        application.setEstimatedProcessingTime("提交后1个工作日内由服务专员联系确认");
    }
    private void fillHotel(ServiceApplication application, ServiceAppointmentSubmitRequest request) {
        Map<String, Object> merchant = optionalMerchant(request.merchantId(), "hotel", null, "酒店不存在或暂未开放");
        applyInstitution(application, merchant, request.merchantName(), request.contactMobile(), false);
        application.setApplicantName(first(clean(request.applicantName()), application.getTalentName()));
        application.setApplicantMobile(first(clean(request.applicantMobile()), application.getTalentMobile()));
        application.setGuestCount(request.guestCount());
        application.setSubsidy(blankToNull(clean(request.subsidy())));
        applyHotelProduct(application, request.productId(), request.productName());
        LocalDate checkIn = date(request.checkInDate(), "入住日期格式不正确", false);
        LocalDate checkOut = date(request.checkOutDate(), "离店日期格式不正确", false);
        if (checkIn == null || checkOut == null) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择入住日期和离店日期");
        }
        if (!checkOut.isAfter(checkIn)) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "离店日期必须晚于入住日期");
        }
        application.setCheckInDate(checkIn);
        application.setCheckOutDate(checkOut);
        application.setExpectedTime(checkIn + " 至 " + checkOut);
        application.setEstimatedProcessingTime("提交后1个工作日内由酒店确认");
    }
    private void fillSpouseEmployment(ServiceApplication application, ServiceAppointmentSubmitRequest request) {
        Map<String, Object> merchant = requiredPremiumMerchant(request.merchantId(), "employment", "配偶推荐就业服务不存在或暂未开放");
        applyInstitution(application, merchant, null, null, false);
        application.setProductName("配偶推荐就业");
        application.setSpouseName(requiredText(request.spouseName(), "请填写配偶姓名"));
        application.setSpouseMobile(sensitiveDataCrypto.encrypt(requiredText(request.spouseMobile(), "请填写配偶手机号")));
        application.setSpouseIdCardNo(sensitiveDataCrypto.encrypt(requiredText(request.spouseIdCardNo(), "请填写配偶证件号").toUpperCase(Locale.ROOT)));
        application.setApplicantIdCardNo(application.getSpouseIdCardNo());
        application.setSpousePoliticalStatus(requiredText(request.spousePoliticalStatus(), "请填写政治面貌"));
        application.setSpouseEducation(requiredText(request.spouseEducation(), "请选择学历"));
        application.setSpouseSchoolMajor(requiredText(request.spouseSchoolMajor(), "请填写毕业院校及专业"));
        application.setSpouseWorkUnit(requiredText(request.spouseWorkUnit(), "请填写现工作单位及职务"));
        application.setSpouseInInstitution(requiredText(request.spouseInInstitution(), "请选择是否为机关事业单位在编人员"));
        application.setSpouseWorkExperience(requiredText(request.spouseWorkExperience(), "请填写工作经历"));
        application.setApplicantName(application.getSpouseName());
        application.setApplicantMobile(sensitiveDataCrypto.decrypt(application.getSpouseMobile()));
        application.setExpectedTime(first(clean(request.expectedTime()), "提交后由服务专员联系"));
        application.setEstimatedProcessingTime("提交后5个工作日内由服务专员联系确认");
    }
    private void fillChildEntrustment(ServiceApplication application, ServiceAppointmentSubmitRequest request, String idempotencyKey) {
        Map<String, Object> merchant = request.merchantId() == null
                ? Map.of()
                : requiredPremiumMerchant(request.merchantId(), "children", "学生托管服务不存在或暂未开放");
        applyInstitution(application, merchant, first(clean(request.merchantName()), "学生托管服务"), request.contactMobile(), false);
        applyChildrenSchool(application, request.schoolId());
        ChildInformation child = childInformation(application.getTalentId(), request, idempotencyKey);
        application.setChildId(child.getId());
        application.setChildName(child.getChildName());
        application.setChildGender(child.getGender());
        application.setChildBirthDate(child.getBirthDate());
        application.setProofFileIds(child.getProofFileIds());
        application.setApplicantName(child.getChildName());
        application.setApplicantMobile(null);
        application.setApplicantIdCardNo(null);
        application.setProductName(first(application.getProductName(), "学生托管"));
        application.setExpectedTime(first(clean(request.expectedTime()), clean(request.expectedDate()), "审核通过后由服务专员联系"));
        application.setEstimatedProcessingTime("提交后3个工作日内完成审核");
        application.setStatus("待审核");
    }
    private void applyMedicalPackage(ServiceApplication application, Long packageId, String packageName) {
        if (packageId == null) {
            application.setPackageName(first(clean(packageName), "体检预约"));
            return;
        }
        Map<String, Object> row = applicationMapper.selectMedicalPackage(packageId, application.getMerchantId());
        if (row == null || row.isEmpty()) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "体检套餐不存在");
        }
        if (intValue(row, "status") != 1 || intValue(row, "stock") == 0) {
            throw new BusinessException(ErrorCode.CONFLICT, first(text(row, "unavailableReason"), "体检套餐当前不可预约"));
        }
        Long merchantId = longValue(row, "merchantId", "merchant_id");
        if (application.getMerchantId() == null && merchantId != null) {
            Map<String, Object> merchant = optionalMerchant(merchantId, "medical", "medical", "体检医院不存在或暂未开放");
            applyInstitution(application, merchant, null, null, true);
        }
        application.setPackageName(text(row, "packageName", "package_name"));
    }
    private void applyHotelProduct(ServiceApplication application, Long productId, String productName) {
        if (productId == null) {
            application.setProductName(requiredText(productName, "请选择酒店房型或套餐"));
            return;
        }
        Map<String, Object> row = applicationMapper.selectMerchantProduct(productId, application.getMerchantId());
        if (row == null || row.isEmpty()) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "酒店房型或套餐不存在");
        }
        if (intValue(row, "status") != 1 || intValue(row, "stock") == 0) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店房型或套餐当前不可预约");
        }
        Long merchantId = longValue(row, "merchantId", "merchant_id");
        if (application.getMerchantId() == null && merchantId != null) {
            Map<String, Object> merchant = optionalMerchant(merchantId, "hotel", null, "酒店不存在或暂未开放");
            applyInstitution(application, merchant, null, null, false);
        }
        application.setProductName(text(row, "productName", "product_name"));
    }
    private void applyChildrenSchool(ServiceApplication application, Long schoolId) {
        if (schoolId == null) {
            return;
        }
        Map<String, Object> row = applicationMapper.selectChildrenSchool(schoolId);
        if (row == null || row.isEmpty() || intValue(row, "enabled") != 1) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "托管学校不存在或暂未开放");
        }
        application.setProductName(text(row, "schoolName", "school_name"));
        application.setContactMobile(first(application.getContactMobile(), text(row, "contactMobile", "contact_mobile")));
        application.setInstitutionAddressSnapshot(first(application.getInstitutionAddressSnapshot(), text(row, "address")));
        application.setInstitutionLatitudeSnapshot(decimalValue(row, "latitude"));
        application.setInstitutionLongitudeSnapshot(decimalValue(row, "longitude"));
    }
    private ChildInformation childInformation(Long talentId, ServiceAppointmentSubmitRequest request, String idempotencyKey) {
        if (request.childId() != null) {
            ChildInformation existing = childInformationMapper.selectOne(new QueryWrapper<ChildInformation>()
                    .eq("id", request.childId())
                    .eq("talent_id", talentId)
                    .eq("deleted", 0)
                    .last("limit 1"));
            if (existing == null) {
                throw new BusinessException(ErrorCode.NOT_FOUND, "子女信息不存在或无权使用");
            }
            return existing;
        }
        ChildInformation duplicate = childInformationMapper.selectOne(new QueryWrapper<ChildInformation>()
                .eq("talent_id", talentId)
                .eq("idempotency_key", idempotencyKey)
                .eq("deleted", 0)
                .last("limit 1"));
        if (duplicate != null) {
            return duplicate;
        }
        List<String> proofIds = proofFileIds(request.proofFileIds());
        if (proofIds.isEmpty()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "请上传亲属关系证明材料");
        }
        ChildInformation child = new ChildInformation();
        child.setTalentId(talentId);
        child.setChildName(requiredText(request.childName(), "请填写子女姓名"));
        child.setGender(gender(request.childGender()));
        child.setBirthDate(childBirthDate(request.childBirthDate()));
        child.setProofFileIds(String.join(",", proofIds));
        child.setAuditStatus("待审核");
        child.setIdempotencyKey(idempotencyKey);
        child.setSubmittedAt(LocalDateTime.now());
        child.setDeleted(0);
        try {
            childInformationMapper.insert(child);
        } catch (DuplicateKeyException exception) {
            ChildInformation existing = childInformationMapper.selectOne(new QueryWrapper<ChildInformation>()
                    .eq("talent_id", talentId)
                    .eq("idempotency_key", idempotencyKey)
                    .eq("deleted", 0)
                    .last("limit 1"));
            if (existing != null) {
                return existing;
            }
            throw exception;
        }
        return child;
    }
    private void applyInstitution(ServiceApplication application, Map<String, Object> merchant, String fallbackName, String fallbackMobile, boolean hospital) {
        if (merchant != null && !merchant.isEmpty()) {
            application.setMerchantId(longValue(merchant, "merchantId", "merchant_id", "id"));
            application.setMerchantName(text(merchant, "merchantName", "merchant_name"));
            application.setContactMobile(text(merchant, "contactMobile", "contact_mobile"));
            application.setInstitutionAddressSnapshot(text(merchant, "address"));
            application.setInstitutionLongitudeSnapshot(decimalValue(merchant, "longitude"));
            application.setInstitutionLatitudeSnapshot(decimalValue(merchant, "latitude"));
            if (hospital) {
                application.setHospitalName(application.getMerchantName());
            }
            return;
        }
        String name = clean(fallbackName);
        if (name.isBlank()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, hospital ? "请选择预约医院" : "请选择服务机构");
        }
        if (hospital) {
            application.setHospitalName(name);
        } else {
            application.setMerchantName(name);
        }
        application.setContactMobile(blankToNull(clean(fallbackMobile)));
    }
    private Map<String, Object> optionalMerchant(Long merchantId, String expectedType, String categoryKey, String message) {
        if (merchantId == null) {
            return Map.of();
        }
        Map<String, Object> merchant = categoryKey == null
                ? applicationMapper.selectMerchantSnapshot(merchantId)
                : applicationMapper.selectPremiumMerchantSnapshot(merchantId, categoryKey);
        if (merchant == null || merchant.isEmpty()) {
            throw new BusinessException(ErrorCode.NOT_FOUND, message);
        }
        if (expectedType != null && !expectedType.equalsIgnoreCase(text(merchant, "merchantType", "merchant_type"))) {
            throw new BusinessException(ErrorCode.CONFLICT, message);
        }
        return merchant;
    }
    private Map<String, Object> requiredPremiumMerchant(Long merchantId, String categoryKey, String message) {
        if (merchantId == null || merchantId <= 0) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择服务机构");
        }
        Map<String, Object> merchant = applicationMapper.selectPremiumMerchantSnapshot(merchantId, categoryKey);
        if (merchant == null || merchant.isEmpty()) {
            throw new BusinessException(ErrorCode.NOT_FOUND, message);
        }
        return merchant;
    }
    private Map<String, Object> activeCard(Long talentId, Long cardId) {
        if (talentId == null && cardId == null) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "人才用户ID或人才卡ID不能为空");
        }
        Map<String, Object> card = applicationMapper.selectActiveCardSnapshot(talentId, cardId);
        if (card == null || card.isEmpty()) {
            throw new BusinessException(ErrorCode.CONFLICT, "未找到有效人才卡");
        }
        return card;
    }
    private ServiceApplication applicationByIdempotencyKey(String idempotencyKey) {
        return applicationMapper.selectOne(new QueryWrapper<ServiceApplication>()
                .eq("idempotency_key", idempotencyKey)
                .eq("deleted", 0)
                .last("limit 1"));
    }
    private Map<String, Object> appResult(ServiceApplication application) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("applicationId", application.getId());
        result.put("applicationNo", application.getApplicationNo());
        result.put("applicationType", application.getApplicationType());
        result.put("applicationTypeName", typeName(application.getApplicationType()));
        result.put("status", application.getStatus());
        result.put("reviewRequired", TYPE_CHILD.equals(application.getApplicationType()));
        result.put("submittedAt", application.getSubmittedAt());
        result.put("serviceName", first(application.getHospitalName(), application.getMerchantName(), application.getProductName(), "人才服务"));
        result.put("appointmentContent", first(application.getProductName(), application.getPackageName(), typeName(application.getApplicationType())));
        result.put("appointmentTime", first(application.getExpectedTime(), "以专员通知为准"));
        result.put("estimatedProcessingTime", first(application.getEstimatedProcessingTime(), "提交后由服务专员联系确认"));
        result.put("contactMobile", first(application.getContactMobile(), ""));
        result.put("childId", application.getChildId());
        return result;
    }
    private String normalizeType(String raw) {
        String value = clean(raw).toUpperCase(Locale.ROOT).replace('-', '_');
        return switch (value) {
            case "MEDICAL", "MEDICAL_CHECKUP", "MEDICAL_RESERVATION", "PHYSICAL_EXAM", "HEALTH_CHECK" -> TYPE_MEDICAL;
            case "FAST_TRACK", "QUICK_CHANNEL", "GREEN_CHANNEL", "MEDICAL_APPLICATION", "HOSPITAL", "HOSPITAL_APPOINTMENT" -> TYPE_FAST_TRACK;
            case "HOTEL", "HOTEL_RESERVATION", "HOTEL_BOOKING" -> TYPE_HOTEL;
            case "SPOUSE", "SPOUSE_EMPLOYMENT", "EMPLOYMENT" -> TYPE_SPOUSE;
            case "CHILD", "CHILDREN", "CHILD_ENTRUSTMENT", "CHILDREN_ENTRUSTMENT", "STUDENT_CARE", "STUDENT_ENTRUSTMENT" -> TYPE_CHILD;
            default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "预约类型仅支持体检、医院快捷通道、酒店、配偶就业、学生托管");
        };
    }
    private String typeName(String type) {
        return switch (type) {
            case TYPE_MEDICAL -> "体检预约";
            case TYPE_FAST_TRACK -> "医院快捷通道";
            case TYPE_HOTEL -> "酒店预约";
            case TYPE_SPOUSE -> "配偶推荐就业";
            case TYPE_CHILD -> "学生托管";
            default -> "预约申请";
        };
    }
    private String idempotencyKey(String raw) {
        String key = clean(raw);
        if (key.isBlank()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "提交凭证不能为空");
        }
        if (key.length() > 128) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "提交凭证长度不能超过128");
        }
        return key;
    }
    private List<String> proofFileIds(List<String> values) {
        if (values == null) {
            return List.of();
        }
        if (values.size() > 3) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "证明材料最多上传3个");
        }
        return values.stream()
                .map(this::clean)
                .filter(item -> !item.isBlank())
                .peek(item -> {
                    if (item.length() > 255) {
                        throw new BusinessException(ErrorCode.BAD_REQUEST, "证明材料文件ID长度不能超过255");
                    }
                })
                .collect(Collectors.toList());
    }
    private String gender(String value) {
        String gender = requiredText(value, "请选择子女性别");
        if (!List.of("男", "女").contains(gender)) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择有效的子女性别");
        }
        return gender;
    }
    private LocalDate childBirthDate(String value) {
        LocalDate date = date(value, "子女出生日期格式不正确", true);
        if (date == null) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择子女出生日期");
        }
        if (date.isAfter(LocalDate.now())) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "子女出生日期不能晚于今天");
        }
        return date;
    }
    private LocalDate date(String value, String message, boolean required) {
        String text = clean(value);
        if (text.isBlank()) {
            if (required) {
                throw new BusinessException(ErrorCode.BAD_REQUEST, message);
            }
            return null;
        }
        try {
            return LocalDate.parse(text);
        } catch (DateTimeParseException exception) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, message);
        }
    }
    private String requiredText(String value, String message) {
        String text = clean(value);
        if (text.isBlank()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, message);
        }
        return text;
    }
    private String first(String... values) {
        for (String value : values) {
            String text = clean(value);
            if (!text.isBlank()) {
                return text;
            }
        }
        return "";
    }
    private String text(Map<String, Object> row, String... keys) {
        Object value = value(row, keys);
        return value == null ? "" : String.valueOf(value).trim();
    }
    private Long longValue(Map<String, Object> row, String... keys) {
        Object value = value(row, keys);
        if (value instanceof Number number) {
            return number.longValue();
        }
        if (value == null || String.valueOf(value).isBlank()) {
            return null;
        }
        return Long.parseLong(String.valueOf(value));
    }
    private int intValue(Map<String, Object> row, String... keys) {
        Object value = value(row, keys);
        if (value instanceof Number number) {
            return number.intValue();
        }
        if (value == null || String.valueOf(value).isBlank()) {
            return 0;
        }
        return Integer.parseInt(String.valueOf(value));
    }
    private BigDecimal decimalValue(Map<String, Object> row, String... keys) {
        Object value = value(row, keys);
        if (value instanceof BigDecimal decimal) {
            return decimal;
        }
        if (value instanceof Number number) {
            return BigDecimal.valueOf(number.doubleValue());
        }
        if (value == null || String.valueOf(value).isBlank()) {
            return null;
        }
        return new BigDecimal(String.valueOf(value));
    }
    private Object value(Map<String, Object> row, String... keys) {
        if (row == null || row.isEmpty()) {
            return null;
        }
        for (String key : keys) {
            if (row.containsKey(key)) {
                return row.get(key);
            }
            String lower = key.toLowerCase(Locale.ROOT);
            String upper = key.toUpperCase(Locale.ROOT);
            if (row.containsKey(lower)) {
                return row.get(lower);
            }
            if (row.containsKey(upper)) {
                return row.get(upper);
            }
        }
        return row.entrySet().stream()
                .filter(entry -> keysMatch(entry.getKey(), keys))
                .map(Map.Entry::getValue)
                .filter(Objects::nonNull)
                .findFirst()
                .orElse(null);
    }
    private boolean keysMatch(String actual, String[] keys) {
        String normalizedActual = actual.replace("_", "").toLowerCase(Locale.ROOT);
        for (String key : keys) {
            if (normalizedActual.equals(key.replace("_", "").toLowerCase(Locale.ROOT))) {
                return true;
            }
        }
        return false;
    }
    private String blankToNull(String value) {
        String text = clean(value);
        return text.isBlank() ? null : text;
    }
    private String clean(String value) {
        return value == null ? "" : value.trim();
    }
}