package com.xuekairui.user.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.xuekairui.common.BusinessException; import com.xuekairui.common.ErrorCode; import com.xuekairui.common.event.ActivityEventRedisService; import com.xuekairui.user.dto.BusinessLicenseReviewResponse; import com.xuekairui.user.entity.BusinessLicense; import com.xuekairui.user.entity.User; import com.xuekairui.user.enums.LicenseStatus; import com.xuekairui.user.enums.MembershipLevel; import com.xuekairui.user.enums.OperationSource; import com.xuekairui.user.enums.OperationType; import com.xuekairui.user.enums.OperatorRole; import com.xuekairui.user.mapper.BusinessLicenseMapper; import com.xuekairui.user.mapper.UserMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; import java.util.Map; /** * 入驻信息服务(封装所有 BusinessLicenseMapper 操作 + 审核业务逻辑) *

* 用户侧:提交/查询入驻信息 * 运营侧:列表查询、审核通过(赠送会员)、驳回 * * @author ProPrice Team * @since 2026-07-09 */ @Slf4j @Service @RequiredArgsConstructor public class BusinessLicenseService { private final BusinessLicenseMapper businessLicenseMapper; private final MembershipService membershipService; private final ActivityEventRedisService activityEventRedisService; private final OperationAuditLogService auditLogService; private final UserMapper userMapper; private final ShopResourceSyncService shopResourceSyncService; // ======================== 查询方法 ======================== /** * 按审核状态 + 手机号关键词查询列表(不传则查全部),LEFT JOIN 查提交者信息,一次 SQL */ public List listByReviewStatus(String reviewStatus, String keyword) { if (reviewStatus != null && reviewStatus.isBlank()) { reviewStatus = null; } if (keyword != null && keyword.isBlank()) { keyword = null; } return businessLicenseMapper.selectReviewList(reviewStatus, keyword); } /** * 根据 ID 查询 */ public BusinessLicense getById(Long id) { return businessLicenseMapper.selectById(id); } /** * 根据用户 ID 查询入驻信息 */ public BusinessLicense getByUserId(Long userId) { return businessLicenseMapper.selectByUserId(userId); } /** * 批量查询用户入驻状态,返回 userId → reviewStatus * 未提交的用户不在 Map 中(视为 NOT_SUBMITTED) */ public Map getLicenseStatusByUserIds(List userIds) { if (userIds == null || userIds.isEmpty()) { return Map.of(); } List licenses = businessLicenseMapper.selectList( new LambdaQueryWrapper() .in(BusinessLicense::getUserId, userIds)); Map result = new HashMap<>(); for (BusinessLicense l : licenses) { result.put(l.getUserId(), l.getReviewStatus()); } return result; } /** * 插入新入驻信息(通用方法,供同步等场景使用) *

必须包含 userId,否则拒绝写入,避免产生无主入驻记录。 */ public void insert(BusinessLicense license) { if (license.getUserId() == null) { throw new BusinessException(ErrorCode.PARAM_ERROR, "入驻信息缺少提交用户ID,无法写入"); } businessLicenseMapper.insert(license); } /** * 更新入驻信息(通用方法,供同步等场景使用) */ public void updateById(BusinessLicense license) { businessLicenseMapper.updateById(license); } /** * 检查用户是否已有其他审核通过的入驻信息(避免重复赠送会员) */ private boolean hasOtherApprovedLicense(Long userId, Long excludeLicenseId) { Long count = businessLicenseMapper.selectCount( new LambdaQueryWrapper() .eq(BusinessLicense::getUserId, userId) .eq(BusinessLicense::getReviewStatus, LicenseStatus.APPROVED.name()) .ne(BusinessLicense::getId, excludeLicenseId)); return count != null && count > 0; } // ======================== 用户侧:提交入驻信息 ======================== /** * 提交或更新入驻信息(用户端) */ @Transactional public void saveOrUpdate(Long userId, BusinessLicenseSaveRequest request) { BusinessLicense existing = getByUserId(userId); final BusinessLicense targetLicense; if (existing == null) { BusinessLicense license = BusinessLicense.builder() .userId(userId) .storeName(request.getStoreName()) .terminalType(request.getTerminalType()) .province(request.getProvince()) .city(request.getCity()) .district(request.getDistrict()) .storeAddress(request.getStoreAddress()) .contactPerson(request.getContactPerson()) .contactPhone(request.getContactPhone()) .licenseImageUrl(request.getBusinessLicenseUrl()) .drugLicenseUrl(request.getDrugLicenseUrl()) .creditCode(request.getCreditCode()) .medicalDeviceClass2Url(request.getMedicalDeviceClass2Url()) .medicalDeviceClass3Url(request.getMedicalDeviceClass3Url()) .reviewStatus(LicenseStatus.PENDING.name()) .showVerifiedBadge(false) .build(); businessLicenseMapper.insert(license); targetLicense = license; log.info("用户{}提交入驻信息:id={}", userId, license.getId()); } else { existing.setStoreName(request.getStoreName()); existing.setTerminalType(request.getTerminalType()); existing.setProvince(request.getProvince()); existing.setCity(request.getCity()); existing.setDistrict(request.getDistrict()); existing.setStoreAddress(request.getStoreAddress()); existing.setContactPerson(request.getContactPerson()); existing.setContactPhone(request.getContactPhone()); existing.setLicenseImageUrl(request.getBusinessLicenseUrl()); existing.setDrugLicenseUrl(request.getDrugLicenseUrl()); if (request.getCreditCode() != null) { existing.setCreditCode(request.getCreditCode()); } if (request.getMedicalDeviceClass2Url() != null) { existing.setMedicalDeviceClass2Url(request.getMedicalDeviceClass2Url()); } if (request.getMedicalDeviceClass3Url() != null) { existing.setMedicalDeviceClass3Url(request.getMedicalDeviceClass3Url()); } existing.setReviewStatus(LicenseStatus.PENDING.name()); businessLicenseMapper.updateById(existing); targetLicense = existing; log.info("用户{}更新入驻信息:id={}", userId, existing.getId()); } // 事务提交后同步客户入驻信息到第三方系统(智价云药店版),失败不阻塞主流程 User submitter = userMapper.selectById(userId); if (submitter != null) { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { syncShopResourceToThirdParty(submitter, targetLicense); } }); } } // ======================== 运营侧:管理员新增入驻 ======================== /** * 管理员后台新增入驻信息(代客户填写) *

* 业务规则: *

* * @param request 入驻信息(含 userId) * @param reviewerId 操作管理员 ID */ @Transactional public void createByAdmin(BusinessLicenseSaveRequest request, Long reviewerId) { Long userId = request.getUserId(); if (userId == null) { throw new BusinessException(ErrorCode.PARAM_ERROR, "必须选择一个客户"); } // 校验客户存在 User submitter = userMapper.selectById(userId); if (submitter == null) { throw new BusinessException(ErrorCode.NOT_FOUND, "客户(ID=" + userId + ")不存在"); } // 校验该客户未提交过入驻信息 BusinessLicense existing = getByUserId(userId); if (existing != null) { throw new BusinessException(ErrorCode.BUSINESS_ERROR, "该客户已提交过入驻信息,无法重复新增,请使用编辑功能"); } // 校验资质必填项 validateRequiredQualifications(request); // 构建入驻记录,状态为 PENDING,需管理员再操作一次审核确认 BusinessLicense license = BusinessLicense.builder() .userId(userId) .storeName(request.getStoreName()) .terminalType(request.getTerminalType()) .province(request.getProvince()) .city(request.getCity()) .district(request.getDistrict()) .storeAddress(request.getStoreAddress()) .contactPerson(request.getContactPerson()) .contactPhone(request.getContactPhone()) .licenseImageUrl(request.getBusinessLicenseUrl()) .drugLicenseUrl(request.getDrugLicenseUrl()) .creditCode(request.getCreditCode()) .medicalDeviceClass2Url(request.getMedicalDeviceClass2Url()) .medicalDeviceClass3Url(request.getMedicalDeviceClass3Url()) .reviewStatus(LicenseStatus.PENDING.name()) .showVerifiedBadge(false) .syncSource("MANUAL") .status(0) .build(); businessLicenseMapper.insert(license); log.info("管理员{}新增入驻信息(待审核确认): licenseId={}, userId={}", reviewerId, license.getId(), userId); // 审计日志:管理员新增,记录变更后快照 String userName = submitter.getNickname() != null ? submitter.getNickname() : String.valueOf(userId); String storeName = license.getStoreName() != null ? license.getStoreName() : "未知药店"; auditLogService.logSuccess( reviewerId != null ? reviewerId : 0L, OperatorRole.ADMIN, OperationType.LICENSE_CREATE, "LICENSE:" + license.getId(), license.getId(), null, Map.of("userId", userId, "userName", userName, "storeName", storeName, "reviewStatus", LicenseStatus.PENDING.name(), "syncSource", "MANUAL"), String.format("管理员新增客户「%s」(药店:%s)的入驻信息,待审核确认", userName, storeName), OperationSource.ADMIN_CREATE); } /** * 校验资质必填项 *

* 营业执照、药品经营许可必填;二类/三类医疗器械至少上传一个。 */ private void validateRequiredQualifications(BusinessLicenseSaveRequest request) { if (isBlank(request.getBusinessLicenseUrl())) { throw new BusinessException(ErrorCode.PARAM_ERROR, "营业执照图片不能为空"); } if (isBlank(request.getDrugLicenseUrl())) { throw new BusinessException(ErrorCode.PARAM_ERROR, "药品经营许可证不能为空"); } boolean hasClass2 = !isBlank(request.getMedicalDeviceClass2Url()); boolean hasClass3 = !isBlank(request.getMedicalDeviceClass3Url()); if (!hasClass2 && !hasClass3) { throw new BusinessException(ErrorCode.PARAM_ERROR, "必须上传二类或三类医疗器械资质(二选一)"); } } private static boolean isBlank(String s) { return s == null || s.isBlank(); } // ======================== 运营侧:管理员编辑入驻 ======================== /** * 管理员编辑入驻信息(可修改客户信息、替换资质图片,保存后即时生效) *

* 业务规则: *

* * @param licenseId 入驻信息 ID * @param request 编辑后的入驻信息 * @param reviewerId 操作管理员 ID */ @Transactional public void editByAdmin(Long licenseId, BusinessLicenseSaveRequest request, Long reviewerId) { BusinessLicense license = getById(licenseId); if (license == null) { throw new BusinessException(ErrorCode.NOT_FOUND, "入驻信息记录不存在"); } // 校验资质必填项 validateRequiredQualifications(request); // 变更前快照 Map beforeData = buildSnapshot(license); Long userId = license.getUserId(); User submitter = userMapper.selectById(userId); if (submitter == null) { throw new BusinessException(ErrorCode.NOT_FOUND, "客户(ID=" + userId + ")不存在"); } // 覆盖更新所有字段(资质图片非空即覆盖) license.setStoreName(request.getStoreName()); license.setTerminalType(request.getTerminalType()); license.setProvince(request.getProvince()); license.setCity(request.getCity()); license.setDistrict(request.getDistrict()); license.setStoreAddress(request.getStoreAddress()); license.setContactPerson(request.getContactPerson()); license.setContactPhone(request.getContactPhone()); license.setLicenseImageUrl(request.getBusinessLicenseUrl()); license.setDrugLicenseUrl(request.getDrugLicenseUrl()); if (request.getCreditCode() != null) { license.setCreditCode(request.getCreditCode()); } if (request.getMedicalDeviceClass2Url() != null) { license.setMedicalDeviceClass2Url(request.getMedicalDeviceClass2Url()); } if (request.getMedicalDeviceClass3Url() != null) { license.setMedicalDeviceClass3Url(request.getMedicalDeviceClass3Url()); } license.setReviewerId(reviewerId); license.setReviewTime(LocalDateTime.now()); businessLicenseMapper.updateById(license); log.info("管理员{}编辑入驻信息: licenseId={}, userId={}", reviewerId, licenseId, userId); // 变更后快照 Map afterData = buildSnapshot(license); // 事务提交后同步到第三方系统 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { syncShopResourceToThirdParty(submitter, license); } }); // 审计日志:管理员编辑,记录变更前/后快照 String userName = submitter.getNickname() != null ? submitter.getNickname() : String.valueOf(userId); String storeName = license.getStoreName() != null ? license.getStoreName() : "未知药店"; auditLogService.logSuccess( reviewerId != null ? reviewerId : 0L, OperatorRole.ADMIN, OperationType.LICENSE_EDIT, "LICENSE:" + license.getId(), license.getId(), beforeData, afterData, String.format("管理员编辑客户「%s」(药店:%s)的入驻信息", userName, storeName), OperationSource.ADMIN_CREATE); } /** * 构建入驻信息快照(用于审计日志的变更前/后对比) */ private Map buildSnapshot(BusinessLicense license) { Map snapshot = new HashMap<>(); snapshot.put("userId", license.getUserId()); snapshot.put("storeName", license.getStoreName()); snapshot.put("terminalType", license.getTerminalType()); snapshot.put("province", license.getProvince()); snapshot.put("city", license.getCity()); snapshot.put("district", license.getDistrict()); snapshot.put("storeAddress", license.getStoreAddress()); snapshot.put("contactPerson", license.getContactPerson()); snapshot.put("contactPhone", license.getContactPhone()); snapshot.put("creditCode", license.getCreditCode()); snapshot.put("businessLicenseUrl", license.getLicenseImageUrl()); snapshot.put("drugLicenseUrl", license.getDrugLicenseUrl()); snapshot.put("medicalDeviceClass2Url", license.getMedicalDeviceClass2Url()); snapshot.put("medicalDeviceClass3Url", license.getMedicalDeviceClass3Url()); snapshot.put("reviewStatus", license.getReviewStatus()); snapshot.put("showVerifiedBadge", license.getShowVerifiedBadge()); return snapshot; } // ======================== 运营侧:审核操作 ======================== /** * 审核通过 *

* 更新审核状态为 APPROVED,展示认证标识。首次审核通过时赠送 30 天高级会员, * 并推送 Redis 事件供活动模块消费(邀请有礼:被邀请人资质通过后发邀请人奖励)。 * * @param licenseId 入驻信息 ID * @param reviewerId 审核人 ID */ @Transactional public void approve(Long licenseId, Long reviewerId) { BusinessLicense license = validateReviewable(licenseId); // 提交用户必须存在,否则拒绝审核 Long userId = license.getUserId(); User submitter = userMapper.selectById(userId); if (submitter == null) { throw new BusinessException(ErrorCode.NOT_FOUND, "提交用户(ID=" + userId + ")不存在,无法审核"); } license.setReviewStatus(LicenseStatus.APPROVED.name()); license.setReviewerId(reviewerId); license.setReviewTime(LocalDateTime.now()); license.setShowVerifiedBadge(true); businessLicenseMapper.updateById(license); // 首次审核通过,赠送 30 天高级会员 if (!hasOtherApprovedLicense(userId, licenseId)) { membershipService.grantOrExtendMembership( userId, MembershipLevel.PRO, 30, "LICENSE", "入驻信息审核通过赠送30天高级会员"); log.info("入驻信息审核通过,赠送用户{} 30天高级会员", userId); // 事务提交后推送 Redis 事件,供活动模块消费 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { try { activityEventRedisService.pushLicenseApproved(userId, license.getId(), "BUSINESS_LICENSE"); } catch (Exception e) { log.warn("存入入驻审核通过事件失败,不影响主流程: userId={}", userId, e); } } }); } // 事务提交后同步客户入驻信息到第三方系统(智价云药店版),失败不阻塞主流程 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { syncShopResourceToThirdParty(submitter, license); } }); // 审计日志:审核通过,记录客户信息 String userName = submitter.getNickname() != null ? submitter.getNickname() : String.valueOf(userId); String storeName = license.getStoreName() != null ? license.getStoreName() : "未知药店"; auditLogService.logSuccess( reviewerId != null ? reviewerId : 0L, OperatorRole.ADMIN, OperationType.LICENSE_APPROVE, "LICENSE:" + license.getId(), license.getId(), null, Map.of("userId", userId, "userName", userName, "storeName", storeName), String.format("审核通过客户「%s」(药店:%s)的入驻申请", userName, storeName), OperationSource.ADMIN_CREATE); log.info("入驻审核通过: licenseId={}, userId={}, userName={}, reviewerId={}", licenseId, userId, userName, reviewerId); } /** * 同步客户入驻信息到第三方系统(智价云药店版) *

* 将客户手机号 + 店铺信息推送到第三方接口,第三方返回该客户的访问令牌。 * 在入驻新增、审核驳回、审核通过三个环节均触发同步,确保第三方实时获取入驻状态变更。 * 同步成功后将第三方 user_id 回写到入驻记录的 externalId 字段,并标记 status=1。 * 第三方不可用或同步失败时标记 status=2,不影响本地主流程, * 后续可通过 status 筛选失败记录进行补推。 */ private void syncShopResourceToThirdParty(User submitter, BusinessLicense license) { try { ShopResourceSyncService.SyncResult result = shopResourceSyncService.syncShopResource(submitter, license); if (result != null && result.getUserId() != null) { license.setExternalId(result.getUserId()); license.setStatus(1); businessLicenseMapper.updateById(license); log.info("同步客户入驻信息到第三方成功: licenseId={}, externalUserId={}, newUser={}", license.getId(), result.getUserId(), result.getNewUser()); } else { license.setStatus(2); businessLicenseMapper.updateById(license); log.warn("同步客户入驻信息到第三方未成功(第三方未返回user_id),标记为2: licenseId={}", license.getId()); } } catch (Exception e) { license.setStatus(2); businessLicenseMapper.updateById(license); log.warn("同步客户入驻信息到第三方失败,标记为2,不影响主流程: licenseId={}, error={}", license.getId(), e.getMessage()); } } /** * 审核驳回 *

* 业务规则: *

    *
  • 只能对 PENDING(新提交/未审核通过)的记录驳回,APPROVED 不允许驳回
  • *
  • 驳回原因必填
  • *
  • 驳回后状态为 REJECTED,客户端/后台客户详情同步移除该条入驻展示
  • *
  • 用户可重新上传提交(saveOrUpdate 会将状态改回 PENDING)
  • *
  • 记录变更前/后快照,便于追溯
  • *
  • 事务提交后同步到第三方系统,失败不阻塞主流程
  • *
* * @param licenseId 入驻信息 ID * @param reviewerId 审核人 ID * @param rejectReason 驳回原因(必填) */ @Transactional public void reject(Long licenseId, Long reviewerId, String rejectReason) { // 驳回原因必填 if (rejectReason == null || rejectReason.isBlank()) { throw new BusinessException(ErrorCode.PARAM_ERROR, "驳回原因不能为空"); } BusinessLicense license = validateReviewable(licenseId); // 审核通过后不允许驳回 if (LicenseStatus.APPROVED.name().equals(license.getReviewStatus())) { throw new BusinessException(ErrorCode.BAD_REQUEST, "该入驻信息已审核通过,不允许驳回"); } // 变更前快照 Map beforeData = buildSnapshot(license); // 提交用户必须存在 Long userId = license.getUserId(); User submitter = userMapper.selectById(userId); if (submitter == null) { throw new BusinessException(ErrorCode.NOT_FOUND, "提交用户(ID=" + userId + ")不存在,无法审核"); } license.setReviewStatus(LicenseStatus.REJECTED.name()); license.setReviewerId(reviewerId); license.setReviewTime(LocalDateTime.now()); license.setShowVerifiedBadge(false); license.setRejectReason(rejectReason); businessLicenseMapper.updateById(license); // 变更后快照 Map afterData = buildSnapshot(license); // 审计日志:审核驳回,记录变更前/后快照 String userName = submitter.getNickname() != null ? submitter.getNickname() : String.valueOf(userId); String storeName = license.getStoreName() != null ? license.getStoreName() : "未知药店"; auditLogService.logSuccess( reviewerId != null ? reviewerId : 0L, OperatorRole.ADMIN, OperationType.LICENSE_REJECT, "LICENSE:" + license.getId(), license.getId(), beforeData, afterData, String.format("驳回客户「%s」(药店:%s)的入驻申请,原因:%s", userName, storeName, rejectReason), OperationSource.ADMIN_CREATE); log.info("入驻审核驳回: licenseId={}, userId={}, userName={}, reviewerId={}, reason={}", licenseId, userId, userName, reviewerId, rejectReason); // 事务提交后同步客户入驻信息到第三方系统(智价云药店版),失败不阻塞主流程 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { syncShopResourceToThirdParty(submitter, license); } }); } /** * 校验入驻信息是否可审核(存在 + 状态为 PENDING) */ private BusinessLicense validateReviewable(Long licenseId) { BusinessLicense license = getById(licenseId); if (license == null) { throw new BusinessException(ErrorCode.NOT_FOUND, "入驻信息记录不存在"); } if (!LicenseStatus.PENDING.name().equals(license.getReviewStatus())) { throw new BusinessException(ErrorCode.BAD_REQUEST, "该入驻信息已审核,无法重复操作"); } return license; } // ======================== 请求 DTO ======================== /** * 入驻信息提交请求参数(用于 Service 层,避免依赖 Controller 层 DTO) */ @lombok.Data @lombok.Builder @lombok.NoArgsConstructor @lombok.AllArgsConstructor public static class BusinessLicenseSaveRequest { /** 客户用户ID(管理员新增时必填,用户自主提交时由 Service 注入) */ private Long userId; private String storeName; private String terminalType; private String province; private String city; private String district; private String storeAddress; private String contactPerson; private String contactPhone; private String businessLicenseUrl; private String drugLicenseUrl; private String creditCode; private String medicalDeviceClass2Url; private String medicalDeviceClass3Url; } }