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