小程序一些接口,加了mybatisplus、swagger
| | |
| | | <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> |
| | |
| | | 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 |
| | |
| | | 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); |
| | | } |
| | | |
| | | } |
| New file |
| | |
| | | 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)); |
| | | } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| | |
| | | |
| | | @Component |
| | | public class AdminAuthInterceptor implements HandlerInterceptor { |
| | | public static final String OPERATOR_ID_ATTRIBUTE = "authenticatedAdminOperatorId"; |
| | | |
| | | private final AdminAuthService service; |
| | | |
| | | public AdminAuthInterceptor(AdminAuthService service) { |
| | |
| | | |
| | | @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; |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | @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 |
| | |
| | | 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) { |
| New file |
| | |
| | | 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)); |
| | | } |
| | | } |
| New file |
| | |
| | | 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)); |
| | | } |
| | | } |
| New file |
| | |
| | | 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)); |
| | | } |
| | | } |
| New file |
| | |
| | | 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)); |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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) { |
| | |
| | | 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, "敏感信息解密失败"); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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)); |
| | | } |
| | | } |
| New file |
| | |
| | | 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)); |
| | | } |
| | | } |
| New file |
| | |
| | | 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")); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; } |
| | | } |
| New file |
| | |
| | | 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; } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| New file |
| | |
| | | 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 |
| | | ) { |
| | | } |
| New file |
| | |
| | | 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 |
| | | ) { |
| | | } |
| New file |
| | |
| | | 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 |
| | | ) { |
| | | } |
| New file |
| | |
| | | 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 |
| | | ) { |
| | | } |
| New file |
| | |
| | | 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 |
| | | ) { |
| | | } |
| New file |
| | |
| | | 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 |
| | | ) { |
| | | } |
| New file |
| | |
| | | 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 |
| | | ) { |
| | | } |
| New file |
| | |
| | | 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() |
| | | ); |
| | | } |
| | | } |
| New file |
| | |
| | | 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 -> "未知"; |
| | | }; |
| | | } |
| | | } |
| New file |
| | |
| | | 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() |
| | | ); |
| | | } |
| | | } |
| New file |
| | |
| | | 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() |
| | | ); |
| | | } |
| | | } |
| New file |
| | |
| | | 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 |
| | | ) { |
| | | } |
| | | } |
| New file |
| | |
| | | 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) { |
| | | } |
| | | } |
| New file |
| | |
| | | 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) { |
| | | } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |