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 必须包含 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
* 业务规则:
*
* 营业执照、药品经营许可必填;二类/三类医疗器械至少上传一个。
*/
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();
}
// ======================== 运营侧:管理员编辑入驻 ========================
/**
* 管理员编辑入驻信息(可修改客户信息、替换资质图片,保存后即时生效)
*
* 业务规则:
*
* 更新审核状态为 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());
}
}
/**
* 审核驳回
*
* 业务规则:
*
*
*
* @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);
}
/**
* 校验资质必填项
*
*
*
* @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
*
*
* @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